Skip to main content

polyc_runtime/
compat.rs

1//! Update compatibility classifier — the safety interlock at the heart of the
2//! update model.
3//!
4//! Pure and free of I/O: given the running build's [`Fingerprint`] and an
5//! available release's fingerprint, [`Fingerprint::classify`] sorts the change
6//! into one of three tiers, and [`StagedBundle::evaluate`] enforces the
7//! refuse-incompatible interlock for config-as-data bundles.
8//!
9//! # The fingerprint
10//!
11//! A [`Fingerprint`] captures only what decides whether one build can talk to
12//! another and read another's data — four axes:
13//!
14//! - the Protobuf/wire descriptor version (`wire_version`),
15//! - the event-log schema version (`eventlog_schema`),
16//! - the CRD `apiVersion` the controller reconciles (`crd_api_version`),
17//! - a hash over the tool catalog (`tool_catalog_hash`) — the stand-in for the
18//!   whole config-as-data surface (prompts, persona defs, model routing).
19//!
20//! The first three are *format* axes: a difference in any of them means two
21//! builds cannot interoperate without a coordinated move. The fourth is the
22//! *config-as-data* axis: it moves without a binary change at all.
23//!
24//! # Classification
25//!
26//! Comparing the running fingerprint against an available one:
27//!
28//! - [`Compatibility::Cold`] — a format axis moved (wire, event log, or CRD).
29//!   Peers cannot interoperate across the change; it needs a coordinated
30//!   redeploy of the whole fleet.
31//! - [`Compatibility::Hot`] — the format axes match and only the config-as-data
32//!   surface moved. The running binary reloads the new catalog; no restart.
33//! - [`Compatibility::Warm`] — the fingerprint is otherwise identical, so the
34//!   only thing that moved is binary internals. A wire- and schema-compatible
35//!   binary swap, picked up on restart.
36//!
37//! Binary releases and config-as-data bundles are separate delivery channels
38//! (the Expo split of native app-store builds versus over-the-air JS bundles):
39//! a warm/cold binary roll never simultaneously bumps the catalog hash, and a
40//! hot config push never bumps a format axis. The classifier reads the
41//! resulting fingerprint delta, so each artifact lands in exactly one tier.
42//!
43//! # The interlock
44//!
45//! A [`StagedBundle`] is a config-as-data payload plus the [`RuntimeTarget`] it
46//! was authored against — the analog of an Expo update's `runtimeVersion`. The
47//! running binary applies the bundle only if its own runtime matches the target
48//! exactly; otherwise [`StagedBundle::evaluate`] returns
49//! [`Compatibility::Incompatible`] and the bundle is refused rather than applied
50//! against a runtime it was never built for.
51
52/// The compatibility fingerprint of a build: the four axes that decide whether
53/// two builds interoperate and can read one another's data.
54///
55/// Only equality matters — the numeric versions are compared for identity, not
56/// ordering, because any difference on a format axis is a coordinated move
57/// regardless of direction.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Fingerprint {
60    /// Protobuf/wire descriptor version the build speaks.
61    pub wire_version: u32,
62    /// Event-log schema version the build reads and writes.
63    pub eventlog_schema: u32,
64    /// CRD `apiVersion` the controller reconciles (e.g. `polychrome.dev/v1`).
65    pub crd_api_version: String,
66    /// Hash over the tool catalog — the config-as-data surface (prompts,
67    /// persona defs, model routing) that moves without a binary change.
68    pub tool_catalog_hash: String,
69}
70
71impl Fingerprint {
72    /// Build a fingerprint from its four axes.
73    #[must_use]
74    pub fn new(
75        wire_version: u32,
76        eventlog_schema: u32,
77        crd_api_version: impl Into<String>,
78        tool_catalog_hash: impl Into<String>,
79    ) -> Self {
80        Self {
81            wire_version,
82            eventlog_schema,
83            crd_api_version: crd_api_version.into(),
84            tool_catalog_hash: tool_catalog_hash.into(),
85        }
86    }
87
88    /// The format-axis subset of this fingerprint — the runtime a config-as-data
89    /// bundle must pin to ride on top of this build.
90    #[must_use]
91    pub fn runtime_target(&self) -> RuntimeTarget {
92        RuntimeTarget {
93            wire_version: self.wire_version,
94            eventlog_schema: self.eventlog_schema,
95            crd_api_version: self.crd_api_version.clone(),
96        }
97    }
98
99    /// Classify `available` relative to this (the running) build.
100    ///
101    /// The result names the least-disruptive move that applies the available
102    /// release: [`Compatibility::Cold`] when a format axis moved,
103    /// [`Compatibility::Hot`] when only the config-as-data surface moved, and
104    /// [`Compatibility::Warm`] when the fingerprint is otherwise identical (a
105    /// wire- and schema-compatible binary swap). This path never refuses — a
106    /// full release brings its own binary, so a format change is a coordinated
107    /// redeploy rather than an incompatibility. See [`StagedBundle::evaluate`]
108    /// for the refuse-incompatible interlock on config-as-data bundles.
109    #[must_use]
110    pub fn classify(&self, available: &Self) -> Compatibility {
111        // Format gate: any wire / event-log / CRD move is a coordinated redeploy
112        // and dominates a config-as-data move layered on top of it.
113        if self.wire_version != available.wire_version
114            || self.eventlog_schema != available.eventlog_schema
115            || self.crd_api_version != available.crd_api_version
116        {
117            return Compatibility::Cold;
118        }
119        // Formats interoperate. A config-as-data move reloads hot.
120        if self.tool_catalog_hash != available.tool_catalog_hash {
121            return Compatibility::Hot;
122        }
123        // Fingerprint-identical: whatever moved is confined to binary internals,
124        // a wire- and schema-compatible swap picked up on restart.
125        Compatibility::Warm
126    }
127}
128
129/// The format-axis subset of a [`Fingerprint`].
130///
131/// These are the wire, event-log, and CRD versions a staged config-as-data
132/// bundle is authored against — the exact-match "runtime version" a bundle
133/// pins, the analog of an Expo update's `runtimeVersion`.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct RuntimeTarget {
136    /// Protobuf/wire descriptor version the bundle was authored against.
137    pub wire_version: u32,
138    /// Event-log schema version the bundle was authored against.
139    pub eventlog_schema: u32,
140    /// CRD `apiVersion` the bundle was authored against.
141    pub crd_api_version: String,
142}
143
144impl RuntimeTarget {
145    /// Build a runtime target from its three format axes.
146    #[must_use]
147    pub fn new(
148        wire_version: u32,
149        eventlog_schema: u32,
150        crd_api_version: impl Into<String>,
151    ) -> Self {
152        Self {
153            wire_version,
154            eventlog_schema,
155            crd_api_version: crd_api_version.into(),
156        }
157    }
158
159    /// The first axis on which `running` fails to satisfy this target, if any.
160    ///
161    /// Axes are checked in wire → event-log → CRD order; the first mismatch is
162    /// reported, so a single [`Incompatibility`] names the reason to refuse.
163    #[must_use]
164    fn mismatch(&self, running: &Fingerprint) -> Option<Incompatibility> {
165        if self.wire_version != running.wire_version {
166            Some(Incompatibility::Wire)
167        } else if self.eventlog_schema != running.eventlog_schema {
168            Some(Incompatibility::EventLog)
169        } else if self.crd_api_version != running.crd_api_version {
170            Some(Incompatibility::Crd)
171        } else {
172            None
173        }
174    }
175}
176
177/// A staged config-as-data bundle: a hot payload (new tool catalog, and with it
178/// prompts / persona defs / model routing) plus the [`RuntimeTarget`] it was
179/// authored against.
180///
181/// The bundle rides on top of whatever binary is already running, so it declares
182/// the runtime it needs and the running binary refuses it unless its own runtime
183/// matches exactly — the refuse-incompatible interlock.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct StagedBundle {
186    /// The runtime this bundle was authored against; the running binary must
187    /// match it exactly to apply the bundle.
188    pub target: RuntimeTarget,
189    /// The config-as-data payload identity this bundle delivers (the new tool
190    /// catalog hash).
191    pub catalog_hash: String,
192}
193
194impl StagedBundle {
195    /// Build a staged bundle from its target runtime and payload hash.
196    #[must_use]
197    pub fn new(target: RuntimeTarget, catalog_hash: impl Into<String>) -> Self {
198        Self {
199            target,
200            catalog_hash: catalog_hash.into(),
201        }
202    }
203
204    /// Decide whether `running` may apply this bundle.
205    ///
206    /// Returns [`Compatibility::Hot`] when the running runtime matches the
207    /// bundle's target exactly (the bundle reloads with no restart), or
208    /// [`Compatibility::Incompatible`] naming the first mismatching axis when it
209    /// does not — the bundle is refused rather than applied against a runtime it
210    /// was never built for.
211    #[must_use]
212    pub fn evaluate(&self, running: &Fingerprint) -> Compatibility {
213        self.target
214            .mismatch(running)
215            .map_or(Compatibility::Hot, Compatibility::Incompatible)
216    }
217}
218
219/// How an available release or staged bundle relates to the running build.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum Compatibility {
222    /// Config-as-data only: reload the new catalog, no restart.
223    Hot,
224    /// Wire- and schema-compatible binary change: restart to apply.
225    Warm,
226    /// A wire / event-log / CRD format change: coordinated fleet redeploy.
227    Cold,
228    /// The running runtime cannot satisfy a staged bundle's target — refused,
229    /// with the axis that failed the exact-match check.
230    Incompatible(Incompatibility),
231}
232
233/// The format axis on which a running runtime fails to satisfy a staged bundle's
234/// target.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum Incompatibility {
237    /// The Protobuf/wire descriptor version differs.
238    Wire,
239    /// The event-log schema version differs.
240    EventLog,
241    /// The reconciled CRD `apiVersion` differs.
242    Crd,
243}
244
245impl std::fmt::Display for Incompatibility {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        let axis = match self {
248            Self::Wire => "the wire protocol version",
249            Self::EventLog => "the event-log schema version",
250            Self::Crd => "the reconciled CRD apiVersion",
251        };
252        write!(f, "{axis} differs from the target the bundle was built for")
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
259
260    use super::*;
261
262    /// Shorthand for a fingerprint over the four axes.
263    fn fp(wire: u32, schema: u32, crd: &str, catalog: &str) -> Fingerprint {
264        Fingerprint::new(wire, schema, crd, catalog)
265    }
266
267    // --- Table-driven classification: (running × available) → expected tier ---
268
269    #[test]
270    fn classify_covers_hot_warm_cold() {
271        let crd = "polychrome.dev/v1";
272        let cases: &[(&str, Fingerprint, Fingerprint, Compatibility)] = &[
273            (
274                "identical fingerprint → warm (binary-internal change on restart)",
275                fp(3, 7, crd, "catalog-a"),
276                fp(3, 7, crd, "catalog-a"),
277                Compatibility::Warm,
278            ),
279            (
280                "only the tool catalog moved → hot (config-as-data reload)",
281                fp(3, 7, crd, "catalog-a"),
282                fp(3, 7, crd, "catalog-b"),
283                Compatibility::Hot,
284            ),
285            (
286                "wire version moved → cold",
287                fp(3, 7, crd, "catalog-a"),
288                fp(4, 7, crd, "catalog-a"),
289                Compatibility::Cold,
290            ),
291            (
292                "event-log schema moved → cold",
293                fp(3, 7, crd, "catalog-a"),
294                fp(3, 8, crd, "catalog-a"),
295                Compatibility::Cold,
296            ),
297            (
298                "CRD apiVersion moved → cold",
299                fp(3, 7, crd, "catalog-a"),
300                fp(3, 7, "polychrome.dev/v2", "catalog-a"),
301                Compatibility::Cold,
302            ),
303            (
304                "format axis moved AND catalog moved → cold dominates hot",
305                fp(3, 7, crd, "catalog-a"),
306                fp(4, 7, crd, "catalog-b"),
307                Compatibility::Cold,
308            ),
309        ];
310
311        for (name, running, available, expected) in cases {
312            assert_eq!(
313                running.classify(available),
314                *expected,
315                "classify case: {name}"
316            );
317        }
318    }
319
320    #[test]
321    fn classify_is_direction_agnostic_on_format_axes() {
322        // A format move is a coordinated redeploy whether the running build is
323        // ahead of or behind the available one.
324        let old = fp(3, 7, "polychrome.dev/v1", "c");
325        let new = fp(4, 8, "polychrome.dev/v2", "c");
326        assert_eq!(old.classify(&new), Compatibility::Cold);
327        assert_eq!(new.classify(&old), Compatibility::Cold);
328    }
329
330    // --- Refuse-incompatible interlock on staged config-as-data bundles ---
331
332    #[test]
333    fn bundle_applies_hot_when_runtime_matches_target() {
334        let running = fp(3, 7, "polychrome.dev/v1", "catalog-a");
335        // Authored against the running runtime, delivering a new catalog.
336        let bundle = StagedBundle::new(running.runtime_target(), "catalog-b");
337        assert_eq!(bundle.evaluate(&running), Compatibility::Hot);
338    }
339
340    #[test]
341    fn bundle_applies_hot_even_when_payload_matches_current_catalog() {
342        // A no-op payload is still a hot-tier application, not a refusal: the
343        // interlock only gates on the runtime target, never the payload.
344        let running = fp(3, 7, "polychrome.dev/v1", "catalog-a");
345        let bundle = StagedBundle::new(running.runtime_target(), "catalog-a");
346        assert_eq!(bundle.evaluate(&running), Compatibility::Hot);
347    }
348
349    #[test]
350    fn bundle_refused_when_running_cannot_satisfy_target() {
351        let running = fp(3, 7, "polychrome.dev/v1", "catalog-a");
352        let cases: &[(&str, RuntimeTarget, Incompatibility)] = &[
353            (
354                "bundle built for a newer wire version",
355                RuntimeTarget::new(4, 7, "polychrome.dev/v1"),
356                Incompatibility::Wire,
357            ),
358            (
359                "bundle built for a newer event-log schema",
360                RuntimeTarget::new(3, 8, "polychrome.dev/v1"),
361                Incompatibility::EventLog,
362            ),
363            (
364                "bundle built for a different CRD apiVersion",
365                RuntimeTarget::new(3, 7, "polychrome.dev/v2"),
366                Incompatibility::Crd,
367            ),
368        ];
369
370        for (name, target, reason) in cases {
371            let bundle = StagedBundle::new(target.clone(), "catalog-b");
372            assert_eq!(
373                bundle.evaluate(&running),
374                Compatibility::Incompatible(*reason),
375                "interlock case: {name}"
376            );
377        }
378    }
379
380    #[test]
381    fn interlock_reports_wire_before_other_axes() {
382        // When several axes are unsatisfiable at once, the first (wire) is named.
383        let running = fp(3, 7, "polychrome.dev/v1", "catalog-a");
384        let bundle = StagedBundle::new(RuntimeTarget::new(9, 9, "polychrome.dev/v9"), "catalog-b");
385        assert_eq!(
386            bundle.evaluate(&running),
387            Compatibility::Incompatible(Incompatibility::Wire),
388        );
389    }
390
391    #[test]
392    fn incompatibility_display_names_the_axis() {
393        assert_eq!(
394            Incompatibility::Wire.to_string(),
395            "the wire protocol version differs from the target the bundle was built for",
396        );
397        assert_eq!(
398            Incompatibility::EventLog.to_string(),
399            "the event-log schema version differs from the target the bundle was built for",
400        );
401        assert_eq!(
402            Incompatibility::Crd.to_string(),
403            "the reconciled CRD apiVersion differs from the target the bundle was built for",
404        );
405    }
406}