Skip to main content

phoxal_runtime_contract/
version.rs

1//! The one compatibility identity that crosses a Phoxal process boundary.
2//!
3//! Two Phoxal binaries speak the same contracts exactly when they were built
4//! from the same [`CompatibilityLine`], so the framework's SemVer version is
5//! that statement in full: one [`FrameworkVersion`] per participant, compared
6//! with [`FrameworkVersion::is_compatible_with`]. There is no second,
7//! per-boundary identity to negotiate, and no way for a bus, launch, or
8//! document claim to disagree with the train that produced it.
9//!
10//! The version a binary records stays exact. It is the provenance a diagnostic
11//! names and the value the frozen `supervisor/connect` bootstrap reports; only
12//! the comparison is the line. What makes the looser comparison truthful is the
13//! compatibility CI: the release gates prove every wire and process surface
14//! against the trains already published on the line, and refuse a candidate
15//! version too small for what its contracts changed.
16//!
17//! The schema tags on persisted documents (`phoxal/runtime-bundle/v0`,
18//! `phoxal/participant-metadata/v0`) are not identities of this kind. They are
19//! parse-time format discriminators owned by the document that carries them: a
20//! reader refuses a tag it does not implement before it looks at any field.
21//!
22//! The identity lives on the process-boundary floor rather than in the crate
23//! that implements a contract, because the record that declares it
24//! ([`crate::metadata::ParticipantMetadata`]) sits below `phoxal-bus`,
25//! `phoxal-protocol`, and `phoxal-manifest` in the graph.
26
27use serde::{Deserialize, Serialize};
28
29use crate::wire_schema::{DescribeWire, WireSchema};
30
31/// The framework train one binary was built from, and therefore the whole of
32/// what it claims about compatibility.
33///
34/// Its canonical wire spelling is the SemVer string itself, e.g. `0.56.2`:
35/// three decimal segments, no prefix, no padding, no pre-release or build
36/// metadata. Equality is exact, so provenance and diagnostics always name the
37/// precise train; compatibility is [`Self::is_compatible_with`], which asks
38/// whether two versions share a [`CompatibilityLine`].
39#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
40pub struct FrameworkVersion {
41    major: u16,
42    minor: u16,
43    patch: u16,
44}
45
46impl FrameworkVersion {
47    /// The canonical spelling of the train this binary was built from.
48    ///
49    /// The crate version is the workspace train version: `[workspace.package]
50    /// version` sets it and exact `=` pins keep every internal dependency on
51    /// the same train.
52    pub const CURRENT_SPELLING: &'static str = env!("CARGO_PKG_VERSION");
53
54    /// The train this binary was built from.
55    ///
56    /// Parsed from [`Self::CURRENT_SPELLING`] during const evaluation, so a
57    /// crate version this type cannot represent fails the build rather than
58    /// reaching a process boundary.
59    pub const CURRENT: Self = match Self::parse(Self::CURRENT_SPELLING.as_bytes()) {
60        Some(version) => version,
61        None => panic!("the crate version is not a canonical <major>.<minor>.<patch> version"),
62    };
63
64    /// Construct one exact framework version.
65    #[must_use]
66    pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
67        Self {
68            major,
69            minor,
70            patch,
71        }
72    }
73
74    /// The version's major component.
75    #[must_use]
76    pub const fn major(self) -> u16 {
77        self.major
78    }
79
80    /// The version's minor component.
81    #[must_use]
82    pub const fn minor(self) -> u16 {
83        self.minor
84    }
85
86    /// The version's patch component.
87    #[must_use]
88    pub const fn patch(self) -> u16 {
89        self.patch
90    }
91
92    /// The SemVer line this version belongs to: pre-1.0 trains break on every
93    /// minor, and a released major breaks only on the major.
94    ///
95    /// The line is what decides compatibility, through
96    /// [`Self::is_compatible_with`]. The version stays exact so a record or a
97    /// diagnostic can still name the train a binary was built from.
98    #[must_use]
99    pub const fn compatibility_line(self) -> CompatibilityLine {
100        if self.major == 0 {
101            CompatibilityLine::PreV1 { minor: self.minor }
102        } else {
103            CompatibilityLine::Stable { major: self.major }
104        }
105    }
106
107    /// Whether a peer built from `other` speaks this version's contracts.
108    ///
109    /// Two trains interoperate exactly when they share a
110    /// [`CompatibilityLine`], so this is the comparison every validator makes:
111    /// a launch, a bundle admission, and a client attachment all ask this
112    /// question and never for equality. The exact version remains available
113    /// for provenance and diagnostics.
114    ///
115    /// The promise is truthful because the compatibility CI enforces it at
116    /// release: the gates prove each candidate's wire and process surfaces
117    /// against the trains already published on its line, and refuse a version
118    /// too small for what its contracts changed. A patch train therefore
119    /// cannot carry a surface change that a peer on the same line would fail
120    /// to speak.
121    #[must_use]
122    pub const fn is_compatible_with(self, other: Self) -> bool {
123        // `CompatibilityLine` is `Eq`, but a derived `PartialEq` is not a const
124        // function, so the two lines are matched here instead.
125        match (self.compatibility_line(), other.compatibility_line()) {
126            (
127                CompatibilityLine::PreV1 { minor },
128                CompatibilityLine::PreV1 { minor: other_minor },
129            ) => minor == other_minor,
130            (
131                CompatibilityLine::Stable { major },
132                CompatibilityLine::Stable { major: other_major },
133            ) => major == other_major,
134            _ => false,
135        }
136    }
137
138    /// Parse the canonical spelling, or `None` when the bytes are anything
139    /// else.
140    ///
141    /// One parser serves const evaluation, [`FromStr`](std::str::FromStr), and
142    /// `Deserialize`, so what a peer reads off the wire, what a diagnostic
143    /// prints, and what the build accepts cannot drift apart.
144    const fn parse(bytes: &[u8]) -> Option<Self> {
145        let (major, index) = match Self::parse_segment(bytes, 0) {
146            Some(parsed) => parsed,
147            None => return None,
148        };
149        if index >= bytes.len() || bytes[index] != b'.' {
150            return None;
151        }
152        let (minor, index) = match Self::parse_segment(bytes, index + 1) {
153            Some(parsed) => parsed,
154            None => return None,
155        };
156        if index >= bytes.len() || bytes[index] != b'.' {
157            return None;
158        }
159        let (patch, index) = match Self::parse_segment(bytes, index + 1) {
160            Some(parsed) => parsed,
161            None => return None,
162        };
163        if index != bytes.len() {
164            return None;
165        }
166        Some(Self::new(major, minor, patch))
167    }
168
169    /// One decimal segment starting at `start`, with the index just past it.
170    ///
171    /// A segment is a non-empty run of ASCII digits that fits in `u16` and
172    /// carries no leading zero, so `0` parses and `00` or `057` does not.
173    const fn parse_segment(bytes: &[u8], start: usize) -> Option<(u16, usize)> {
174        let mut index = start;
175        let mut value: u16 = 0;
176        while index < bytes.len() && bytes[index].is_ascii_digit() {
177            let digit = (bytes[index] - b'0') as u16;
178            value = match value.checked_mul(10) {
179                Some(scaled) => scaled,
180                None => return None,
181            };
182            value = match value.checked_add(digit) {
183                Some(added) => added,
184                None => return None,
185            };
186            index += 1;
187        }
188        if index == start {
189            return None;
190        }
191        if bytes[start] == b'0' && index - start > 1 {
192            return None;
193        }
194        Some((value, index))
195    }
196}
197
198/// The SemVer line a [`FrameworkVersion`] belongs to, and therefore the unit
199/// two Phoxal binaries have to agree on.
200///
201/// Pre-1.0 the breaking axis is the minor; from 1.0 on it is the major. Two
202/// versions on one line interoperate, which is what
203/// [`FrameworkVersion::is_compatible_with`] asks.
204#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
205pub enum CompatibilityLine {
206    /// A `0.x` train, whose line is its minor.
207    PreV1 { minor: u16 },
208    /// A released train, whose line is its major.
209    Stable { major: u16 },
210}
211
212impl std::fmt::Display for CompatibilityLine {
213    /// The line spelled the way an operator names a compatible release:
214    /// `0.58.x` before 1.0, `1.x` after it. One spelling here keeps every
215    /// diagnostic that offers a remediation from inventing its own.
216    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        match self {
218            Self::PreV1 { minor } => write!(formatter, "0.{minor}.x"),
219            Self::Stable { major } => write!(formatter, "{major}.x"),
220        }
221    }
222}
223
224impl std::fmt::Display for FrameworkVersion {
225    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch)
227    }
228}
229
230impl std::str::FromStr for FrameworkVersion {
231    type Err = FrameworkVersionError;
232
233    fn from_str(value: &str) -> Result<Self, Self::Err> {
234        Self::parse(value.as_bytes()).ok_or_else(|| FrameworkVersionError {
235            value: value.to_owned(),
236        })
237    }
238}
239
240/// A framework version that is not the canonical SemVer spelling of a version
241/// this type can represent.
242#[derive(Clone, Debug, thiserror::Error)]
243#[error("invalid framework version '{value}'; expected <major>.<minor>.<patch>")]
244pub struct FrameworkVersionError {
245    value: String,
246}
247
248impl Serialize for FrameworkVersion {
249    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
250        serializer.collect_str(self)
251    }
252}
253
254impl<'de> Deserialize<'de> for FrameworkVersion {
255    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
256        let value = String::deserialize(deserializer)?;
257        Self::parse(value.as_bytes())
258            .ok_or_else(|| serde::de::Error::custom(FrameworkVersionError { value }))
259    }
260}
261
262impl DescribeWire for FrameworkVersion {
263    // Invariant: this states what the `Serialize` above writes - one string
264    // holding the canonical SemVer spelling, never the three-field struct the
265    // type is made of.
266    fn wire_schema() -> WireSchema {
267        WireSchema::opaque("FrameworkVersion", WireSchema::String)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn the_wire_spelling_is_the_semver_string_and_round_trips() {
277        let version = FrameworkVersion::new(0, 57, 2);
278        assert_eq!(version.to_string(), "0.57.2");
279        assert_eq!(
280            serde_json::to_string(&version).expect("a framework version serializes"),
281            "\"0.57.2\""
282        );
283        assert_eq!(
284            serde_json::from_str::<FrameworkVersion>("\"0.57.2\"").expect("the spelling parses"),
285            version
286        );
287        assert_eq!(
288            "0.57.2"
289                .parse::<FrameworkVersion>()
290                .expect("the spelling parses"),
291            version
292        );
293        assert_eq!(
294            (version.major(), version.minor(), version.patch()),
295            (0, 57, 2)
296        );
297    }
298
299    /// The wire accepts one spelling. A prefix, a missing segment, pre-release
300    /// metadata, padding, or a structural object are all a different document
301    /// than the one this contract defines.
302    #[test]
303    fn every_non_canonical_spelling_is_rejected() {
304        for value in [
305            "\"v0.57.2\"",
306            "\"0.57\"",
307            "\"0.57.2.1\"",
308            "\"0.57.2-rc.1\"",
309            "\"0.57.2+build.5\"",
310            "\"0.057.2\"",
311            "\"00.57.2\"",
312            "\"0.57.2 \"",
313            "\" 0.57.2\"",
314            "\"\"",
315            r#"{"major":0,"minor":57,"patch":2}"#,
316        ] {
317            assert!(
318                serde_json::from_str::<FrameworkVersion>(value).is_err(),
319                "{value} must not parse as a framework version"
320            );
321        }
322        assert!("0.57.2-rc.1".parse::<FrameworkVersion>().is_err());
323        assert!("65536.0.0".parse::<FrameworkVersion>().is_err());
324    }
325
326    /// The declared wire shape and what the serializer writes are checked
327    /// against each other rather than asserted, so the hand-written
328    /// declaration cannot drift from the impl beside it.
329    #[test]
330    fn the_declared_wire_shape_is_the_shape_the_serializer_writes() {
331        let value = serde_json::to_value(FrameworkVersion::new(0, 57, 2))
332            .expect("a framework version serializes");
333        assert_eq!(FrameworkVersion::wire_schema().conforms(&value), Ok(()));
334        assert_eq!(
335            FrameworkVersion::wire_schema().canonical_json(),
336            r#"{"kind":"opaque","name":"FrameworkVersion","wire":{"kind":"string"}}"#
337        );
338    }
339
340    /// The const-evaluated constant and the crate version are the same fact,
341    /// checked here through the runtime parser so a const-eval mistake cannot
342    /// hide behind itself.
343    #[test]
344    fn current_is_the_crate_version() {
345        assert_eq!(
346            FrameworkVersion::CURRENT.to_string(),
347            env!("CARGO_PKG_VERSION")
348        );
349        assert_eq!(
350            env!("CARGO_PKG_VERSION")
351                .parse::<FrameworkVersion>()
352                .expect("the crate version is a canonical framework version"),
353            FrameworkVersion::CURRENT
354        );
355        assert_eq!(
356            FrameworkVersion::CURRENT_SPELLING,
357            FrameworkVersion::CURRENT.to_string()
358        );
359    }
360
361    #[test]
362    fn the_compatibility_line_is_the_minor_before_v1_and_the_major_after() {
363        assert_eq!(
364            FrameworkVersion::new(0, 57, 2).compatibility_line(),
365            CompatibilityLine::PreV1 { minor: 57 }
366        );
367        assert_eq!(
368            FrameworkVersion::new(0, 58, 0).compatibility_line(),
369            CompatibilityLine::PreV1 { minor: 58 }
370        );
371        assert_eq!(
372            FrameworkVersion::new(1, 4, 9).compatibility_line(),
373            CompatibilityLine::Stable { major: 1 }
374        );
375        assert_eq!(
376            FrameworkVersion::new(2, 0, 0).compatibility_line(),
377            CompatibilityLine::Stable { major: 2 }
378        );
379    }
380
381    /// A line is spelled once, as the release an operator would ask for.
382    #[test]
383    fn a_line_is_spelled_as_the_release_it_names() {
384        assert_eq!(
385            FrameworkVersion::new(0, 58, 2)
386                .compatibility_line()
387                .to_string(),
388            "0.58.x"
389        );
390        assert_eq!(
391            FrameworkVersion::new(1, 4, 9)
392                .compatibility_line()
393                .to_string(),
394            "1.x"
395        );
396    }
397
398    /// Two versions on one line are still two versions: equality distinguishes
399    /// them so a record and a diagnostic can name the exact train. Only
400    /// compatibility treats them as one.
401    #[test]
402    fn versions_on_the_same_line_are_not_equal_but_are_compatible() {
403        let earlier = FrameworkVersion::new(0, 57, 0);
404        let later = FrameworkVersion::new(0, 57, 1);
405        assert_eq!(earlier.compatibility_line(), later.compatibility_line());
406        assert_ne!(earlier, later);
407        assert!(earlier.is_compatible_with(later));
408        assert!(later.is_compatible_with(earlier));
409    }
410
411    /// Compatibility is line equality: pre-1.0 the minor is the break, from
412    /// 1.0 on the major is, and the two eras never interoperate.
413    #[test]
414    fn compatibility_is_the_line_and_the_line_is_the_break() {
415        let pre_v1 = FrameworkVersion::new(0, 58, 0);
416        assert!(pre_v1.is_compatible_with(FrameworkVersion::new(0, 58, 7)));
417        assert!(!pre_v1.is_compatible_with(FrameworkVersion::new(0, 59, 0)));
418        assert!(!pre_v1.is_compatible_with(FrameworkVersion::new(0, 57, 9)));
419
420        let stable = FrameworkVersion::new(1, 4, 2);
421        assert!(stable.is_compatible_with(FrameworkVersion::new(1, 9, 0)));
422        assert!(!stable.is_compatible_with(FrameworkVersion::new(2, 0, 0)));
423        assert!(!stable.is_compatible_with(pre_v1));
424        assert!(!pre_v1.is_compatible_with(stable));
425
426        assert!(FrameworkVersion::CURRENT.is_compatible_with(FrameworkVersion::CURRENT));
427    }
428}