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}