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