polyc_runtime/compat.rs
1//! Update compatibility classifier — the safety interlock at the heart of the
2//! update model (see the update PRD and `docs/reference/operator-mailbox.md`).
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.uno/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.uno/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.uno/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.uno/v1", "c");
325 let new = fp(4, 8, "polychrome.uno/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.uno/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.uno/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.uno/v1", "catalog-a");
352 let cases: &[(&str, RuntimeTarget, Incompatibility)] = &[
353 (
354 "bundle built for a newer wire version",
355 RuntimeTarget::new(4, 7, "polychrome.uno/v1"),
356 Incompatibility::Wire,
357 ),
358 (
359 "bundle built for a newer event-log schema",
360 RuntimeTarget::new(3, 8, "polychrome.uno/v1"),
361 Incompatibility::EventLog,
362 ),
363 (
364 "bundle built for a different CRD apiVersion",
365 RuntimeTarget::new(3, 7, "polychrome.uno/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.uno/v1", "catalog-a");
384 let bundle = StagedBundle::new(RuntimeTarget::new(9, 9, "polychrome.uno/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}