Skip to main content

polyc_runtime/
hot_reload.rs

1//! Hot config-as-data reload — apply a verified bundle at the turn boundary,
2//! with no process restart (see the update PRD and [`crate::compat`] /
3//! [`crate::stager`]).
4//!
5//! This is the OTA-instant layer. A config-as-data bundle — system prompts,
6//! persona definitions, the tool catalog, model routing — is staged, verified,
7//! and then swapped into a single in-memory handle the turn loop reads *at
8//! turn-start*. The next turn of any conversation picks up the new bundle; an
9//! in-flight turn keeps the bundle it captured when it began. No binary changes,
10//! no restart.
11//!
12//! # The turn-boundary handle
13//!
14//! [`HotConfig<C>`] is an atomically-swappable pointer to the live config-as-data
15//! value. A turn reads it exactly once, at turn-start, via
16//! [`HotConfig::current`] — the returned [`Arc`] is that turn's *pinned
17//! snapshot*. Because the snapshot is an owned `Arc`, a swap that lands while the
18//! turn is still running does not disturb it: the in-flight turn finishes on the
19//! value it started with, and only the *next* [`HotConfig::current`] observes the
20//! new value. That is the whole boundary guarantee, and it holds because the read
21//! happens once, at the start.
22//!
23//! The control plane already runs exactly this pattern for one config dimension —
24//! model routing is held behind an `Arc<ArcSwap<ModelRef>>` and read once per
25//! turn at dispatch, so a live `model set` lands on the next turn of every
26//! conversation. [`HotConfig`] generalizes that mechanism to the whole
27//! config-as-data surface and ties it to the verify + classify + rollback
28//! machinery below.
29//!
30//! # The classifier gate
31//!
32//! Only a bundle that classifies [`Compatibility::Hot`] takes this no-restart
33//! path. [`ensure_hot`] refuses any other verdict, and [`apply_hot_reload`]
34//! routes a [`StagedBundle`] through the compat interlock
35//! ([`StagedBundle::evaluate`]) so a bundle authored against a different runtime
36//! is refused before anything is applied. A warm or cold change is a binary
37//! release, delivered on the other channel and picked up on restart — it never
38//! arrives here as a config bundle.
39//!
40//! # Reusing the stager
41//!
42//! The apply itself reuses [`crate::stager`] wholesale: the same signature
43//! verify (an unverified bundle is never applied), the same health check, and
44//! the same pointer-flip rollback. The only thing that differs for a hot reload
45//! is the [`Activator`] — instead of a symlink or image swap, it is a
46//! config-pointer swap on a [`HotConfig`]. [`HotConfigActivator`] is that
47//! activator, so a caller drives an ordinary [`Stager`] whose activator swaps the
48//! live config value, and a failed health check flips the value back to the
49//! previous bundle just as a binary rollback flips a symlink back.
50
51use std::collections::HashMap;
52use std::sync::{Arc, Mutex, PoisonError};
53
54use arc_swap::ArcSwap;
55use thiserror::Error;
56
57use crate::compat::{Compatibility, Fingerprint, StagedBundle};
58use crate::stager::{
59    Activator, HealthCheck, Outcome, ReleaseId, SignatureVerifier, StageError, Stager, UpdateSource,
60};
61
62/// An atomically-swappable handle to the live config-as-data value the turn loop
63/// reads at turn-start.
64///
65/// Clone to share the same underlying cell: a reader (the turn loop, per turn)
66/// and a writer (a verified hot reload) hold clones and operate on one value.
67/// [`HotConfig::current`] is the turn-start read; [`HotConfig::install`] is the
68/// swap. A swap is visible only to reads that begin after it, so an in-flight
69/// turn is never disturbed.
70pub struct HotConfig<C> {
71    cell: Arc<ArcSwap<C>>,
72}
73
74impl<C> Clone for HotConfig<C> {
75    fn clone(&self) -> Self {
76        // Share the same cell — never a deep copy of the config value.
77        Self {
78            cell: Arc::clone(&self.cell),
79        }
80    }
81}
82
83impl<C> HotConfig<C> {
84    /// Seed the handle with an initial config value.
85    #[must_use]
86    pub fn new(initial: C) -> Self {
87        Self {
88            cell: Arc::new(ArcSwap::from_pointee(initial)),
89        }
90    }
91
92    /// Seed the handle from an already-shared config value.
93    #[must_use]
94    pub fn from_arc(initial: Arc<C>) -> Self {
95        Self {
96            cell: Arc::new(ArcSwap::new(initial)),
97        }
98    }
99
100    /// The live config-as-data value, read at turn-start.
101    ///
102    /// The returned [`Arc`] is the calling turn's pinned snapshot: a later
103    /// [`HotConfig::install`] swaps the cell, but this owned handle keeps
104    /// pointing at the value that was live when the turn began. Read it once, at
105    /// the start of a turn, and thread that snapshot through the rest of the
106    /// turn.
107    #[must_use]
108    pub fn current(&self) -> Arc<C> {
109        self.cell.load_full()
110    }
111
112    /// Swap the live config-as-data value.
113    ///
114    /// The swap is atomic and lock-free. Turns that already read
115    /// [`HotConfig::current`] keep their snapshot; the next turn to read sees
116    /// `next`.
117    pub fn install(&self, next: Arc<C>) {
118        self.cell.store(next);
119    }
120}
121
122/// A config-pointer-swap [`Activator`]: the hot-reload analog of the stager's
123/// symlink or image swap.
124///
125/// It resolves a [`ReleaseId`] to a staged config value and swaps the live
126/// [`HotConfig`] to it, so driving a [`Stager`] with this activator makes
127/// `stage_and_apply` verify, apply, health-check, and roll back a *config*
128/// reload with no changes to the stager itself. The previous bundle's value
129/// stays registered, so an auto-rollback (a flip back to the previous release)
130/// is a pointer swap, never a re-download.
131///
132/// Clone to hold a staging handle alongside the copy the stager owns: both
133/// clones share the same live handle and the same registry.
134pub struct HotConfigActivator<C> {
135    live: HotConfig<C>,
136    // Release identity → the config value that release delivers. The stager
137    // flips forward to a newly-staged release and, on rollback, back to the
138    // previously-committed one, so both must resolve here.
139    staged: Arc<Mutex<HashMap<ReleaseId, Arc<C>>>>,
140}
141
142impl<C> Clone for HotConfigActivator<C> {
143    fn clone(&self) -> Self {
144        Self {
145            live: self.live.clone(),
146            staged: Arc::clone(&self.staged),
147        }
148    }
149}
150
151impl<C> HotConfigActivator<C> {
152    /// Build an activator over `live`, registering its current value under
153    /// `initial` so a rollback to the starting release resolves.
154    ///
155    /// `initial` is the [`ReleaseId`] the [`Stager`] is constructed with as its
156    /// live version; registering the current value under it makes the first
157    /// auto-rollback a pointer swap back to the value that is live right now.
158    #[must_use]
159    pub fn new(live: HotConfig<C>, initial: ReleaseId) -> Self {
160        let mut staged = HashMap::new();
161        staged.insert(initial, live.current());
162        Self {
163            live,
164            staged: Arc::new(Mutex::new(staged)),
165        }
166    }
167
168    /// Register the config value a release delivers, before it is applied.
169    ///
170    /// Call this for a release as it is staged (so `activate` can resolve the
171    /// forward flip). A committed release stays registered, so a later rollback
172    /// to it is a pointer swap.
173    pub fn stage(&self, release: ReleaseId, value: Arc<C>) {
174        self.lock().insert(release, value);
175    }
176
177    /// The live handle this activator swaps — clone it to read the config the
178    /// same way a turn does.
179    #[must_use]
180    pub fn live(&self) -> HotConfig<C> {
181        self.live.clone()
182    }
183
184    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<ReleaseId, Arc<C>>> {
185        // A poisoned registry still holds valid entries — a panic elsewhere does
186        // not corrupt the release → value map — so recover the guard rather than
187        // propagate the poison and fail an otherwise-sound reload.
188        self.staged.lock().unwrap_or_else(PoisonError::into_inner)
189    }
190}
191
192impl<C> Activator for HotConfigActivator<C> {
193    fn activate(&self, release: &ReleaseId) -> Result<(), StageError> {
194        let value = self.lock().get(release).map(Arc::clone);
195        value.map_or_else(
196            // A flip to an unregistered release cannot swap a value it never
197            // received — refuse it as an activation failure rather than silently
198            // leaving the live config unchanged.
199            || {
200                Err(StageError::Activate(format!(
201                    "no staged config registered for release {release}"
202                )))
203            },
204            |value| {
205                self.live.install(value);
206                Ok(())
207            },
208        )
209    }
210}
211
212/// Why a hot config reload was refused or could not complete.
213#[derive(Debug, Error)]
214pub enum HotReloadError {
215    /// The bundle did not classify [`Compatibility::Hot`] against the running
216    /// runtime, so it does not take the no-restart config-reload path. A warm or
217    /// cold change is a binary release picked up on restart; an incompatible
218    /// bundle was authored against a runtime this build cannot satisfy.
219    #[error("refused: only a hot config bundle reloads without a restart ({})", verdict_label(.0))]
220    NotHot(Compatibility),
221    /// The stager could not stage, verify, or apply the bundle. Wraps the
222    /// underlying [`StageError`] — most importantly [`StageError::Unverified`],
223    /// which means the signature did not verify and nothing was applied.
224    #[error(transparent)]
225    Stage(#[from] StageError),
226}
227
228/// A short, plain-language name for a refused verdict, for [`HotReloadError`].
229const fn verdict_label(verdict: &Compatibility) -> &'static str {
230    match verdict {
231        Compatibility::Hot => "hot",
232        Compatibility::Warm => "a binary change that needs a restart",
233        Compatibility::Cold => "a format change that needs a coordinated redeploy",
234        Compatibility::Incompatible(_) => "built for a different runtime",
235    }
236}
237
238/// Gate a classification verdict onto the hot path: [`Ok`] only for
239/// [`Compatibility::Hot`].
240///
241/// This is the classifier interlock in one place — the reload proceeds only when
242/// the change is config-as-data on a matching runtime. Any other verdict
243/// (`Warm`, `Cold`, or `Incompatible`) is refused.
244///
245/// # Errors
246///
247/// Returns [`HotReloadError::NotHot`] carrying the refused verdict for anything
248/// other than [`Compatibility::Hot`].
249pub fn ensure_hot(verdict: &Compatibility) -> Result<(), HotReloadError> {
250    if *verdict == Compatibility::Hot {
251        Ok(())
252    } else {
253        Err(HotReloadError::NotHot(verdict.clone()))
254    }
255}
256
257/// Apply a staged config-as-data `bundle` to the live config via `stager`, only
258/// if it classifies hot against `running`.
259///
260/// The sequence is: classify (the [`StagedBundle::evaluate`] interlock from
261/// [`crate::compat`]) → refuse anything but [`Compatibility::Hot`] → hand the
262/// release to the [`Stager`], which verifies the signature, applies it (a
263/// config-pointer swap when the stager's activator is a [`HotConfigActivator`]),
264/// health-checks, and rolls back on failure. The classifier gate runs *before*
265/// the stager touches anything, so an incompatible bundle never reaches verify
266/// or apply.
267///
268/// On [`Outcome::Committed`] the live [`HotConfig`] now serves the new bundle to
269/// the next turn; on [`Outcome::RolledBack`] the health check failed and the
270/// pointer flipped back, so the live config is honestly the previous bundle.
271///
272/// # Errors
273///
274/// Returns [`HotReloadError::NotHot`] when the bundle does not classify hot
275/// (nothing is staged, verified, or applied), or [`HotReloadError::Stage`] when
276/// the stager cannot stage, verify (an unverified bundle is refused), or apply
277/// the release.
278pub fn apply_hot_reload<S, V, A, H>(
279    running: &Fingerprint,
280    bundle: &StagedBundle,
281    stager: &mut Stager<S, V, A, H>,
282    release: &ReleaseId,
283) -> Result<Outcome, HotReloadError>
284where
285    S: UpdateSource,
286    V: SignatureVerifier,
287    A: Activator,
288    H: HealthCheck,
289{
290    ensure_hot(&bundle.evaluate(running))?;
291    Ok(stager.stage_and_apply(release)?)
292}
293
294#[cfg(test)]
295mod tests {
296    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
297
298    use std::path::PathBuf;
299
300    use super::*;
301    use crate::compat::RuntimeTarget;
302    use crate::stager::{Health, StagedArtifact};
303
304    /// A stand-in config-as-data value: a tag identifies which bundle a turn read.
305    #[derive(Debug, Clone, PartialEq, Eq)]
306    struct Cfg {
307        tag: &'static str,
308    }
309
310    impl Cfg {
311        fn new(tag: &'static str) -> Arc<Self> {
312            Arc::new(Self { tag })
313        }
314    }
315
316    /// The running build's fingerprint used across these tests.
317    fn running() -> Fingerprint {
318        Fingerprint::new(3, 7, "polychrome.uno/v1", "catalog-v1")
319    }
320
321    /// A bundle authored against the running runtime — classifies hot.
322    fn hot_bundle() -> StagedBundle {
323        StagedBundle::new(running().runtime_target(), "catalog-v2")
324    }
325
326    fn artifact_for(release: &ReleaseId) -> StagedArtifact {
327        StagedArtifact {
328            release: release.clone(),
329            staged_path: PathBuf::from(format!("/var/lib/polychrome/staged/{release}")),
330            bundle: hot_bundle(),
331            signed_bytes: format!("bytes-of-{release}").into_bytes(),
332            signature: vec![0xAB; 4],
333            signer_public_key: vec![0xCD; 4],
334        }
335    }
336
337    fn ok_source(release: &ReleaseId) -> Result<StagedArtifact, StageError> {
338        Ok(artifact_for(release))
339    }
340
341    // --- The turn-boundary invariant, on the handle in isolation ----------------
342
343    #[test]
344    fn a_swap_lands_on_the_next_turn_never_an_in_flight_one() {
345        let live = HotConfig::new(Cfg { tag: "v1" });
346
347        // Turn A begins: it snapshots the config-as-data at turn-start.
348        let turn_a = live.current();
349
350        // A verified hot bundle is applied between turns.
351        live.install(Cfg::new("v2"));
352
353        // Turn B begins AFTER the swap and snapshots at its own turn-start.
354        let turn_b = live.current();
355
356        // Turn A, still in flight, finishes on the config it started with...
357        assert_eq!(
358            turn_a.tag, "v1",
359            "in-flight turn keeps its turn-start snapshot"
360        );
361        // ...while the next turn reads the newly installed config.
362        assert_eq!(turn_b.tag, "v2", "the next turn reads the new bundle");
363        // The boundary is exactly turn-start: A never observes v2 mid-turn.
364        assert_eq!(live.current().tag, "v2", "the live handle now serves v2");
365    }
366
367    // --- The classifier gate ----------------------------------------------------
368
369    #[test]
370    fn ensure_hot_admits_only_hot() {
371        assert!(ensure_hot(&Compatibility::Hot).is_ok());
372        for verdict in [
373            Compatibility::Warm,
374            Compatibility::Cold,
375            Compatibility::Incompatible(crate::compat::Incompatibility::Wire),
376        ] {
377            let err = ensure_hot(&verdict).unwrap_err();
378            assert!(
379                matches!(err, HotReloadError::NotHot(v) if v == verdict),
380                "non-hot verdict must be refused: {verdict:?}",
381            );
382        }
383    }
384
385    #[test]
386    fn apply_refuses_a_bundle_built_for_another_runtime_before_touching_anything() {
387        let live = HotConfig::new(Cfg { tag: "v1" });
388        let activator = HotConfigActivator::new(live.clone(), ReleaseId::new("v1"));
389        // A bundle authored against a newer wire version → Incompatible, not hot.
390        let bundle = StagedBundle::new(RuntimeTarget::new(4, 7, "polychrome.uno/v1"), "catalog-v2");
391
392        let mut stager = Stager::new(
393            |_: &ReleaseId| -> Result<StagedArtifact, StageError> {
394                panic!("download must not run for a non-hot bundle")
395            },
396            |_: &StagedArtifact| panic!("verify must not run for a non-hot bundle"),
397            activator,
398            || panic!("health check must not run for a non-hot bundle"),
399            ReleaseId::new("v1"),
400        );
401
402        let err =
403            apply_hot_reload(&running(), &bundle, &mut stager, &ReleaseId::new("v2")).unwrap_err();
404
405        assert!(matches!(err, HotReloadError::NotHot(_)));
406        // Nothing was applied: the live config is untouched.
407        assert_eq!(live.current().tag, "v1");
408    }
409
410    // --- Reuse of the stager's verify -------------------------------------------
411
412    #[test]
413    fn apply_never_swaps_an_unverified_bundle() {
414        let live = HotConfig::new(Cfg { tag: "v1" });
415        let activator = HotConfigActivator::new(live.clone(), ReleaseId::new("v1"));
416        activator.stage(ReleaseId::new("v2"), Cfg::new("v2"));
417
418        let mut stager = Stager::new(
419            ok_source,
420            // Signature does not verify.
421            |_: &StagedArtifact| false,
422            activator,
423            || panic!("health check must not run for an unverified bundle"),
424            ReleaseId::new("v1"),
425        );
426
427        let err = apply_hot_reload(
428            &running(),
429            &hot_bundle(),
430            &mut stager,
431            &ReleaseId::new("v2"),
432        )
433        .unwrap_err();
434
435        assert!(matches!(err, HotReloadError::Stage(StageError::Unverified)));
436        // The verify refusal means the pointer never flipped.
437        assert_eq!(live.current().tag, "v1");
438    }
439
440    // --- The end-to-end hot reload: gate + verify + swap at the boundary --------
441
442    #[test]
443    fn a_verified_hot_reload_swaps_the_config_for_the_next_turn() {
444        let live = HotConfig::new(Cfg { tag: "v1" });
445        let activator = HotConfigActivator::new(live.clone(), ReleaseId::new("v1"));
446        // Register the value the new release delivers, as it is staged.
447        activator.stage(ReleaseId::new("v2"), Cfg::new("v2"));
448
449        let mut stager = Stager::new(
450            ok_source,
451            |_: &StagedArtifact| true,
452            activator,
453            || Health::Healthy,
454            ReleaseId::new("v1"),
455        );
456
457        // A turn already in flight captured its snapshot before the reload.
458        let in_flight = live.current();
459
460        let outcome = apply_hot_reload(
461            &running(),
462            &hot_bundle(),
463            &mut stager,
464            &ReleaseId::new("v2"),
465        )
466        .unwrap();
467
468        assert_eq!(
469            outcome,
470            Outcome::Committed {
471                version: ReleaseId::new("v2"),
472            }
473        );
474        // The in-flight turn finishes on the prior config...
475        assert_eq!(
476            in_flight.tag, "v1",
477            "in-flight turn completes on the prior bundle"
478        );
479        // ...and the next turn reads the newly installed one.
480        assert_eq!(
481            live.current().tag,
482            "v2",
483            "the next turn reads the reloaded bundle"
484        );
485    }
486
487    #[test]
488    fn a_failed_health_check_flips_the_config_back_to_the_previous_bundle() {
489        let live = HotConfig::new(Cfg { tag: "v1" });
490        let activator = HotConfigActivator::new(live.clone(), ReleaseId::new("v1"));
491        activator.stage(ReleaseId::new("v2"), Cfg::new("v2"));
492
493        let mut stager = Stager::new(
494            ok_source,
495            |_: &StagedArtifact| true,
496            activator,
497            || Health::Unhealthy("readiness probe timed out".to_owned()),
498            ReleaseId::new("v1"),
499        );
500
501        let outcome = apply_hot_reload(
502            &running(),
503            &hot_bundle(),
504            &mut stager,
505            &ReleaseId::new("v2"),
506        )
507        .unwrap();
508
509        assert_eq!(
510            outcome,
511            Outcome::RolledBack {
512                stayed_on: ReleaseId::new("v1"),
513                reason: "readiness probe timed out".to_owned(),
514            }
515        );
516        // The config was swapped to v2, then flipped back — a pointer swap, not a
517        // re-download — so the next turn honestly reads the previous bundle.
518        assert_eq!(
519            live.current().tag,
520            "v1",
521            "rollback restores the previous bundle"
522        );
523    }
524
525    #[test]
526    fn not_hot_error_reads_plainly() {
527        let msg = HotReloadError::NotHot(Compatibility::Warm).to_string();
528        assert_eq!(
529            msg,
530            "refused: only a hot config bundle reloads without a restart \
531             (a binary change that needs a restart)",
532        );
533        for banned in ["sorry", "please", "unfortunately"] {
534            assert!(!msg.to_lowercase().contains(banned));
535        }
536    }
537}