minerva 0.2.0

Causal ordering for distributed systems
extern crate alloc;

use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use thiserror::Error;

use super::VersionVector;

/// Version tag for the canonical `VersionVector` wire encoding (PRD 0007). A distinct
/// version space from the `Kairos` tag: the two frames evolve independently.
const VV_WIRE_V1: u8 = 0x01;
/// Frame header: one version byte plus a `u32` big-endian entry count.
const VV_WIRE_HEADER_LEN: usize = 5;
/// One canonical entry: a `u32` `station_id` then its `u64` counter, both big-endian.
/// Fixed width is what keeps the length check, and so the allocation bound, `O(1)`.
const VV_WIRE_ENTRY_LEN: usize = 12;

impl VersionVector {
    /// Exact byte length of [`to_bytes`](Self::to_bytes), computed without
    /// allocating the frame.
    ///
    /// Embedding protocols use this to enforce their outer byte budget before
    /// asking the canonical encoder to allocate. Saturation preserves totality
    /// on targets where an in-memory map could theoretically exceed `usize`.
    #[must_use]
    pub fn encoded_len(&self) -> usize {
        VV_WIRE_HEADER_LEN.saturating_add(self.counters.len().saturating_mul(VV_WIRE_ENTRY_LEN))
    }

    /// Encodes this vector as its canonical version-1 wire frame (PRD 0007):
    ///
    /// 1. one version byte;
    /// 2. a `u32` big-endian entry count;
    /// 3. that many fixed 12-byte `(station_id: u32, counter: u64)` entries.
    ///
    /// Entries run in strictly ascending station order, which is the map's
    /// own order. Every counter is `>= 1` (canonical form). Infallible; the
    /// only allocation is the returned frame.
    ///
    /// Equal vectors encode to identical bytes, so the frame is a sound content hash or
    /// dedup key. It is deliberately *not* a causal sort key: a `VersionVector` is only
    /// partially ordered, so byte order cannot track happens-before (contrast
    /// [`Kairos::to_bytes`](crate::kairos::Kairos::to_bytes), whose frame sorts causally).
    ///
    /// # Byte identity is protocol, not just a durable format
    ///
    /// Since `pinax`'s anti-entropy session v1 (its S57, verified 2026-07-02), this
    /// frame rides at the head of every session body, split off by
    /// [`from_prefix`](Self::from_prefix) from network bytes. The session's own
    /// version byte therefore transitively pins frame v1: a frame v2 would be a
    /// session *wire break* for that consumer, not merely a new durable format. Any
    /// v2 ships only with advance notice to the named consumers, and a coordinated
    /// bump rides the embedding protocol's own version byte (the same discipline as
    /// the `Kairos` frame's signature-preimage pin; PRD 0007).
    #[must_use]
    pub fn to_bytes(&self) -> Vec<u8> {
        // A canonical map holds at most one entry per `u32` station, so the count fits a
        // `u32` for every vector representable in memory; saturate rather than panic on
        // the unreachable 2^32-entry case, since `to_bytes` is infallible.
        let count = u32::try_from(self.counters.len()).unwrap_or(u32::MAX);
        let mut out = Vec::with_capacity(self.encoded_len());
        out.push(VV_WIRE_V1);
        out.extend_from_slice(&count.to_be_bytes());
        for (&station, &counter) in &self.counters {
            out.extend_from_slice(&station.to_be_bytes());
            out.extend_from_slice(&counter.to_be_bytes());
        }
        out
    }

    /// Decodes exactly one canonical frame produced by [`to_bytes`](Self::to_bytes),
    /// rejecting trailing bytes.
    ///
    /// # Errors
    /// [`DecodeError::UnexpectedLength`] if `bytes` is shorter than the count prefix
    /// requires or carries trailing bytes; otherwise the variants of
    /// [`from_prefix`](Self::from_prefix). Never panics on adversarial input.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, DecodeError> {
        let (vector, tail) = Self::from_prefix(bytes)?;
        if tail.is_empty() {
            Ok(vector)
        } else {
            // `from_bytes` is `from_prefix` plus "the tail must be empty"; the consumed
            // prefix is the exact frame the count described.
            Err(DecodeError::UnexpectedLength {
                expected: bytes.len() - tail.len(),
                found: bytes.len(),
            })
        }
    }

    /// Decodes a canonical frame from the start of `bytes`, returning the
    /// vector and the unconsumed tail. This is the seam that composes the
    /// `Event` envelope `(stamp, deps)`: the tail is the caller's payload,
    /// handed back untouched (PRD 0007). Since `pinax`'s anti-entropy session
    /// (its S57) it is also a *network* trust boundary. It decodes the horizon
    /// frame off the head of every session body. Its guarantees therefore hold
    /// against adversarial bytes, not against durable bytes alone. The fuzz
    /// target `envelope_from_prefix` drives exactly this composition.
    ///
    /// Decode validates structure *and* canonical form, so a frame the type's own
    /// constructors could never produce (a zero counter, unsorted or duplicate stations)
    /// is rejected rather than admitted, and an accepted frame round-trips bit-for-bit.
    /// Allocation is bounded by the input length, never by the untrusted count: the
    /// required frame length is checked against `bytes.len()` before the map is built,
    /// computed in `u64` so it cannot overflow `usize` on a 32-bit target.
    ///
    /// # Errors
    /// [`DecodeError::UnknownVersion`] for an unrecognized version byte;
    /// [`DecodeError::UnexpectedLength`] if `bytes` is shorter than `5 + count * 12`;
    /// [`DecodeError::ZeroCounter`] for a zero counter; and
    /// [`DecodeError::NonAscendingStations`] for entries not in strictly ascending
    /// station order. Never panics on adversarial input.
    pub fn from_prefix(bytes: &[u8]) -> Result<(Self, &[u8]), DecodeError> {
        let header: [u8; VV_WIRE_HEADER_LEN] = bytes
            .get(..VV_WIRE_HEADER_LEN)
            .and_then(|h| h.try_into().ok())
            .ok_or(DecodeError::UnexpectedLength {
                expected: VV_WIRE_HEADER_LEN,
                found: bytes.len(),
            })?;
        let [version, c0, c1, c2, c3] = header;
        if version != VV_WIRE_V1 {
            return Err(DecodeError::UnknownVersion(version));
        }
        let count = u32::from_be_bytes([c0, c1, c2, c3]);
        // Required frame length in `u64`: on `wasm32` `usize` is 32-bit, so `count * 12`
        // could wrap before the comparison. A claim larger than the buffer is rejected
        // here, in `O(1)`, before any per-entry allocation.
        let frame_len = VV_WIRE_HEADER_LEN as u64 + u64::from(count) * VV_WIRE_ENTRY_LEN as u64;
        let expected = usize::try_from(frame_len).unwrap_or(usize::MAX);
        if bytes.len() < expected {
            return Err(DecodeError::UnexpectedLength {
                expected,
                found: bytes.len(),
            });
        }
        let (frame, tail) = bytes.split_at(expected);
        let mut counters = BTreeMap::new();
        let mut previous: Option<u32> = None;
        for entry in frame
            .get(VV_WIRE_HEADER_LEN..)
            .unwrap_or_default()
            .chunks_exact(VV_WIRE_ENTRY_LEN)
        {
            // `chunks_exact` yields 12-byte slices; the conversion cannot fail, but a
            // fallible map keeps the parser panic-free by construction.
            let entry: [u8; VV_WIRE_ENTRY_LEN] =
                entry
                    .try_into()
                    .map_err(|_| DecodeError::UnexpectedLength {
                        expected,
                        found: bytes.len(),
                    })?;
            let [s0, s1, s2, s3, counter @ ..] = entry;
            let station = u32::from_be_bytes([s0, s1, s2, s3]);
            let counter = u64::from_be_bytes(counter);
            if counter == 0 {
                return Err(DecodeError::ZeroCounter { station });
            }
            if let Some(previous) = previous
                && station <= previous
            {
                return Err(DecodeError::NonAscendingStations {
                    previous,
                    found: station,
                });
            }
            previous = Some(station);
            let _ = counters.insert(station, counter);
        }
        Ok((Self { counters }, tail))
    }
}

/// Decode failure for a [`VersionVector`] wire frame (PRD 0007).
///
/// Distinct from [`kairos::DecodeError`](crate::kairos::DecodeError): canonical form is
/// a `metis` concept, so `metis` owns this error rather than growing a `metis`-specific
/// variant onto the `kairos` stamp error (the dependency runs `metis -> kairos`, never
/// the reverse). `#[non_exhaustive]` so a future version's validation failure is additive.
#[non_exhaustive]
#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecodeError {
    /// The leading version byte is not a version this build understands.
    #[error("unknown VersionVector wire version: {0:#04x}")]
    UnknownVersion(u8),
    /// The input was shorter than the frame the count prefix requires, or (for
    /// [`from_bytes`](VersionVector::from_bytes)) carried trailing bytes.
    #[error("unexpected VersionVector frame length: expected {expected}, found {found}")]
    UnexpectedLength {
        /// The frame length the count prefix requires (`5 + count * 12`).
        expected: usize,
        /// The actual length of the supplied input.
        found: usize,
    },
    /// An entry carried a zero counter. Canonical form holds a zero-count station absent,
    /// so no `VersionVector` constructor can produce one.
    #[error("non-canonical VersionVector: zero counter for station {station}")]
    ZeroCounter {
        /// The station whose entry carried the illegal zero counter.
        station: u32,
    },
    /// Entries were not in strictly ascending `station_id` order (unsorted, or a
    /// duplicate station), so the frame is non-canonical.
    #[error("non-canonical VersionVector: station {found} is not strictly after {previous}")]
    NonAscendingStations {
        /// The preceding entry's `station_id`.
        previous: u32,
        /// The offending `station_id`, not strictly greater than `previous`.
        found: u32,
    },
}