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}