ai_memory/runtime_context.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.x (issue #1174 follow-up #1192 / #1196) — cross-surface
5//! `RuntimeContext` for substrate state that spans the HTTP daemon,
6//! the MCP stdio binary, and the CLI.
7//!
8//! ## Why this module exists
9//!
10//! Pre-#1192 / #1196 the substrate carried a handful of process-wide
11//! `static` slots — webhook HMAC override, transcript decompression
12//! cap, audit sink + sequence counter, session-recall tracker, X25519
13//! keypair cache — that none of `AppState` (HTTP), the MCP
14//! `Connection`, or the per-command CLI handlers could own jointly.
15//! PR7 (#1192) and PR8 (#1196) on `release/v0.7.0` identified that the
16//! correct refactor is a single `Arc<RuntimeContext>` that all three
17//! surfaces can hold and that internally backs every former static.
18//!
19//! This module is that struct. The design preserves the existing
20//! public surface (e.g. `crate::config::active_hooks_hmac_secret`,
21//! `crate::audit::emit`, `crate::reranker::global_session_recall_tracker`)
22//! — those accessors now delegate to the process-wide
23//! [`RuntimeContext`] singleton. The wire / chain / cache semantics are
24//! byte-for-byte unchanged; the storage merely moved from
25//! `static FOO: ... = ...` to a field on [`RuntimeContext`].
26//!
27//! ## Singleton vs. injected instance
28//!
29//! [`RuntimeContext`] is a struct, not a global. Tests construct fresh
30//! instances via [`RuntimeContext::default`] and exercise the typed
31//! accessors directly; production code wires a single instance through
32//! [`RuntimeContext::install_global`] at boot so the legacy free
33//! functions (`crate::config::set_active_hooks_hmac_secret`,
34//! `crate::audit::emit`, etc.) keep working without churning ~60
35//! callsites across the codebase.
36//!
37//! The `global()` accessor returns `&'static RuntimeContext` (via a
38//! `LazyLock`-style `OnceLock` seeded on first read). Install order
39//! matters only at the very first read; once a context is installed it
40//! sticks for the lifetime of the process, matching the prior
41//! `OnceLock` / `RwLock<Option<...>>` semantics of every individual
42//! extracted static.
43
44use std::collections::HashMap;
45use std::sync::atomic::AtomicU64;
46use std::sync::{Arc, Mutex, OnceLock, RwLock};
47
48/// Cross-surface substrate state.
49///
50/// Held as `Arc<RuntimeContext>` by every long-lived runtime: HTTP
51/// daemon `AppState`, MCP stdio dispatch, CLI command handlers. Fields
52/// are read-mostly; mutable-config fields use `RwLock` for the rare
53/// reload path, and the audit chain uses interior `Mutex` to keep emit
54/// ordering atomic across producers (matching the pre-#1192
55/// `audit::SINK` lock posture).
56///
57/// #1262 — `Debug` is implemented manually below to redact the
58/// `hooks_hmac_secret` field; the derived impl would have leaked the
59/// raw HMAC bytes through any `{:?}` print. #1258 — the manual `Drop`
60/// impl zeroizes the secret on scope exit.
61#[derive(Default)]
62pub struct RuntimeContext {
63 /// v0.7.0 K7 — resolved webhook HMAC override. `None` when the
64 /// operator has not configured `[hooks.subscription] hmac_secret`,
65 /// in which case per-subscription secrets carry the signing key.
66 /// Mutable so the K7 integration tests can flip mid-process; in
67 /// production this is set once at boot from
68 /// `AppConfig::effective_hooks_hmac_secret`.
69 ///
70 /// #1262 — manual `Debug` impl redacts this to `<redacted>` when
71 /// present; #1258 — manual `Drop` impl zeroizes the secret on
72 /// scope exit.
73 pub hooks_hmac_secret: RwLock<Option<String>>,
74
75 /// I1 cap (#628 agent-3 follow-up) — per-call transcript
76 /// decompression cap. `None` means "use the compiled default"
77 /// (`crate::transcripts::MAX_DECOMPRESSED_BYTES`). Operators raise
78 /// the cap via `[transcripts] max_decompressed_bytes = ...` and
79 /// boot writes the resolved value here.
80 pub max_decompressed_bytes: RwLock<Option<usize>>,
81
82 /// V-4 audit chain state — the load-bearing tamper-evidence
83 /// substrate. See [`AuditState`] for the chain invariants the
84 /// `crate::audit::*` public surface preserves.
85 pub audit: Arc<AuditState>,
86
87 /// Per-session recall tracker (Form 2 #518 / #1091). Tracks the
88 /// last N memory ids returned to each `session_id` so the recall
89 /// hot path can apply a +0.05 boost to repeat candidates. Process-
90 /// global by design — operator restart clears every session's
91 /// recent set.
92 pub recall_tracker: Arc<crate::reranker::SessionRecallTracker>,
93
94 /// Per-agent X25519 keypair cache. Populated lazily by
95 /// `crate::encryption::get_or_create_keypair` on first encrypt /
96 /// decrypt for an `agent_id`; persists for the lifetime of the
97 /// process. A future issue will swap this for an on-disk store;
98 /// the in-memory shape lets the encryption substrate land without
99 /// forcing a key-rotation tool design decision in the same patch.
100 pub keypair_cache: Arc<Mutex<HashMap<String, crate::encryption::Keypair>>>,
101
102 /// #1691 — process-global cross-encoder reranker for the autonomous
103 /// tier. Installed once at `serve` boot (HTTP daemon) when the
104 /// resolved tier enables the cross-encoder, so the HTTP recall
105 /// handler applies the SAME neural rerank stage the MCP/CLI recall
106 /// paths already run (closing the documented HTTP-skips-rerank drift,
107 /// formerly the n23 NOTE in `handlers/recall.rs`). Empty on
108 /// keyword/semantic/smart tiers and in tests — recall then runs
109 /// without the rerank stage. Interior `OnceLock` keeps
110 /// `RuntimeContext: Default` so no `AppState` construction site has to
111 /// change to carry the handle.
112 pub reranker: OnceLock<Arc<crate::reranker::BatchedReranker>>,
113}
114
115impl std::fmt::Debug for RuntimeContext {
116 /// #1262 — redact `hooks_hmac_secret` so accidental `{:?}` prints
117 /// of the runtime context never leak the HMAC signing key. The
118 /// remaining fields are non-secret and use their derived Debug.
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 let secret_present = self
121 .hooks_hmac_secret
122 .read()
123 .map(|g| g.is_some())
124 .unwrap_or(false);
125 f.debug_struct("RuntimeContext")
126 .field(
127 "hooks_hmac_secret",
128 &if secret_present {
129 crate::REDACTED_PLACEHOLDER
130 } else {
131 "<unset>"
132 },
133 )
134 .field("max_decompressed_bytes", &self.max_decompressed_bytes)
135 .field("audit", &self.audit)
136 .field("recall_tracker", &"<recall_tracker>")
137 .field("keypair_cache", &"<keypair_cache>")
138 .field(
139 "reranker",
140 &if self.reranker.get().is_some() {
141 "<reranker>"
142 } else {
143 "<unset>"
144 },
145 )
146 .finish()
147 }
148}
149
150impl Drop for RuntimeContext {
151 /// #1258 — zeroize the resolved `hooks_hmac_secret` (if any) on
152 /// scope exit so the HMAC signing key does not linger on the heap
153 /// past the runtime context's lifetime.
154 fn drop(&mut self) {
155 if let Ok(mut guard) = self.hooks_hmac_secret.write() {
156 if let Some(secret) = guard.as_mut() {
157 use zeroize::Zeroize;
158 secret.zeroize();
159 }
160 }
161 }
162}
163
164/// V-4 audit chain state. Owns the same `(sink, sequence)` pair the
165/// pre-#1192 `src/audit.rs` module-level statics owned; the public
166/// `crate::audit::*` functions delegate here.
167///
168/// The chain invariants this struct preserves byte-for-byte:
169///
170/// 1. `sink` is wrapped in `RwLock<Option<Arc<AuditSink>>>` so the
171/// `init` path can swap the sink atomically and `emit` can clone the
172/// `Arc` without holding the lock for the file write.
173/// 2. `sequence` is an `AtomicU64` so concurrent emit threads agree on
174/// a monotonic counter; it is seeded from the trailing record's
175/// sequence on `init` (F2 fix — sequence survives daemon restart).
176/// 3. The `AuditSink::inner` mutex serialises the hash-chain head
177/// update + line write, so the chain is consistent across producer
178/// threads.
179#[derive(Debug, Default)]
180pub struct AuditState {
181 /// Process-wide audit sink. `None` when audit is disabled. Wrapped
182 /// in `RwLock` (rather than `OnceLock`) so tests can swap in an
183 /// in-memory sink between cases without leaking state across runs.
184 pub sink: RwLock<Option<Arc<crate::audit::AuditSink>>>,
185 /// Per-process monotonic sequence counter. Starts at 0; first emit
186 /// produces sequence 1. `init` reseeds from the trailing record's
187 /// sequence so `audit verify` doesn't trip on a restart-induced
188 /// reset (F2 round-2 fix).
189 pub sequence: AtomicU64,
190}
191
192// ---------------------------------------------------------------------------
193// Process-wide singleton
194// ---------------------------------------------------------------------------
195
196/// Process-wide [`RuntimeContext`] handle. Seeded on first
197/// [`global()`] call (lazy-init via [`RuntimeContext::default`]) or
198/// explicitly installed at boot via [`install_global`].
199///
200/// Stored as `Arc<RuntimeContext>` so callers can either borrow the
201/// singleton via [`RuntimeContext::global`] (free) or clone the `Arc`
202/// via [`RuntimeContext::global_arc`] when they need to keep a typed
203/// handle on a struct field (e.g. `AppState::runtime`). Arc clones are
204/// cheap (refcount increment) so neither posture pays an allocation.
205static GLOBAL: OnceLock<Arc<RuntimeContext>> = OnceLock::new();
206
207impl RuntimeContext {
208 /// Install a custom [`RuntimeContext`] as the process-wide singleton.
209 /// Idempotent in the same sense as `OnceLock::set` — the first
210 /// install wins; subsequent calls are silently ignored (the
211 /// returned `Result` is suppressed to keep the boot path
212 /// infallible against an accidental double-install).
213 ///
214 /// Boot code typically does NOT call this — the lazy-init in
215 /// [`global()`] is sufficient, and the legacy `set_*` accessors
216 /// (`crate::config::set_active_hooks_hmac_secret` etc.) populate
217 /// the inner fields via interior mutability. The hook exists for
218 /// the rare test that wants to pin a non-default starting state.
219 pub fn install_global(ctx: RuntimeContext) {
220 // Drop the Result — last-writer-loses matches the prior
221 // `OnceLock::set` posture used by the per-static
222 // `OnceLock::get_or_init` calls this struct replaced.
223 let _ = GLOBAL.set(Arc::new(ctx));
224 }
225
226 /// Return a borrowed reference to the process-wide
227 /// [`RuntimeContext`]. Seeds the singleton with
228 /// [`RuntimeContext::default`] on first call so callers never see
229 /// `None` — same `get_or_init` semantics as the per-static
230 /// `OnceLock`s this struct replaced.
231 ///
232 /// The returned reference is `&'static` because the singleton
233 /// `Arc<RuntimeContext>` lives inside a process-wide `OnceLock`
234 /// that itself never drops — once seeded, the `Arc` (and the
235 /// `RuntimeContext` it owns) outlives the entire process.
236 #[must_use]
237 pub fn global() -> &'static RuntimeContext {
238 Self::global_arc_ref()
239 }
240
241 /// Internal — return a reference to the `Arc<RuntimeContext>`
242 /// stored in the singleton slot. Auto-derefed by [`global()`].
243 fn global_arc_ref() -> &'static Arc<RuntimeContext> {
244 GLOBAL.get_or_init(|| Arc::new(RuntimeContext::default()))
245 }
246
247 /// Return a cloned `Arc<RuntimeContext>` to the process-wide
248 /// singleton. Cheap (refcount increment, no allocation). Used by
249 /// long-lived runtime structs (notably `AppState::runtime`) that
250 /// want to keep a typed handle on a field rather than re-grabbing
251 /// the global via [`RuntimeContext::global`] on every access.
252 #[must_use]
253 pub fn global_arc() -> Arc<RuntimeContext> {
254 Arc::clone(Self::global_arc_ref())
255 }
256
257 /// #1691 — install the process-global cross-encoder reranker. First
258 /// writer wins (`OnceLock` semantics); subsequent installs are
259 /// silently ignored. Called once at `serve` boot via interior
260 /// mutability on the singleton — the same `set_*`-on-the-global
261 /// pattern the other extracted statics use, so no `AppState`
262 /// construction site changes.
263 pub fn install_reranker(&self, reranker: Arc<crate::reranker::BatchedReranker>) {
264 let _ = self.reranker.set(reranker);
265 }
266
267 /// #1691 — borrow the installed cross-encoder reranker, if any.
268 /// `None` on non-autonomous tiers and in tests, where HTTP recall
269 /// runs without the neural rerank stage.
270 #[must_use]
271 pub fn reranker(&self) -> Option<&Arc<crate::reranker::BatchedReranker>> {
272 self.reranker.get()
273 }
274}
275
276// ---------------------------------------------------------------------------
277// Tests
278// ---------------------------------------------------------------------------
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn install_and_read_reranker_on_fresh_context() {
286 // #1691 — the reranker slot is empty on a fresh context (every
287 // non-autonomous tier + all test scaffolds), installs once, and
288 // is first-writer-wins. Exercised on a FRESH instance so it never
289 // pollutes the process-global singleton other tests share.
290 let ctx = RuntimeContext::default();
291 assert!(ctx.reranker().is_none(), "fresh context has no reranker");
292 ctx.install_reranker(Arc::new(crate::reranker::BatchedReranker::new(
293 crate::reranker::CrossEncoder::new(),
294 )));
295 assert!(ctx.reranker().is_some(), "reranker present after install");
296 // First-writer-wins (OnceLock): a second install is ignored and
297 // does not panic.
298 ctx.install_reranker(Arc::new(crate::reranker::BatchedReranker::new(
299 crate::reranker::CrossEncoder::new(),
300 )));
301 assert!(ctx.reranker().is_some());
302 }
303
304 #[test]
305 fn runtime_context_default_is_constructible() {
306 // Pin: every field has a sensible default that lets unit
307 // tests exercise the struct without booting the daemon.
308 let ctx = RuntimeContext::default();
309 assert!(ctx.hooks_hmac_secret.read().unwrap().is_none());
310 assert!(ctx.max_decompressed_bytes.read().unwrap().is_none());
311 assert!(ctx.audit.sink.read().unwrap().is_none());
312 assert_eq!(
313 ctx.audit.sequence.load(std::sync::atomic::Ordering::SeqCst),
314 0
315 );
316 assert_eq!(ctx.recall_tracker.session_count(), 0);
317 assert_eq!(ctx.keypair_cache.lock().unwrap().len(), 0);
318 }
319
320 #[test]
321 fn runtime_context_global_returns_stable_handle() {
322 // Pin: two reads of `global()` from the same process MUST
323 // return the same backing struct so the legacy free-fn
324 // surface keeps observing the same mutations.
325 let a = RuntimeContext::global() as *const RuntimeContext;
326 let b = RuntimeContext::global() as *const RuntimeContext;
327 assert_eq!(a, b, "global() must return a stable reference");
328 }
329
330 #[test]
331 fn runtime_context_audit_state_default() {
332 // Pin: AuditState defaults match the prior module-level
333 // `static SINK: RwLock::new(None)` + `static SEQUENCE:
334 // AtomicU64::new(0)` shape.
335 let audit = AuditState::default();
336 assert!(audit.sink.read().unwrap().is_none());
337 assert_eq!(audit.sequence.load(std::sync::atomic::Ordering::SeqCst), 0);
338 }
339
340 #[test]
341 fn debug_impl_redacts_secret_when_present() {
342 // #1262 — the manual Debug impl must never leak the HMAC bytes.
343 let ctx = RuntimeContext::default();
344 *ctx.hooks_hmac_secret.write().unwrap() = Some("super-secret-hmac-key".to_string());
345 let rendered = format!("{ctx:?}");
346 assert!(
347 !rendered.contains("super-secret-hmac-key"),
348 "Debug leaked the raw HMAC secret: {rendered}"
349 );
350 assert!(rendered.contains(crate::REDACTED_PLACEHOLDER));
351 assert!(rendered.contains("RuntimeContext"));
352 }
353
354 #[test]
355 fn debug_impl_marks_unset_secret() {
356 // The other Debug branch — `<unset>` when no secret is present.
357 let ctx = RuntimeContext::default();
358 let rendered = format!("{ctx:?}");
359 assert!(rendered.contains("<unset>"), "got: {rendered}");
360 assert!(rendered.contains("<recall_tracker>"));
361 assert!(rendered.contains("<keypair_cache>"));
362 }
363
364 #[test]
365 fn drop_zeroizes_secret_on_scope_exit() {
366 // #1258 — the Drop impl zeroizes the secret. We can't observe the
367 // freed heap directly, but we can drive the Drop body (the
368 // `if let Ok(...) { if let Some(...) }` arms) on a context that
369 // carries a secret, proving it runs without panic.
370 let ctx = RuntimeContext::default();
371 *ctx.hooks_hmac_secret.write().unwrap() = Some("to-be-zeroized".to_string());
372 drop(ctx); // exercises the Drop body's populated-secret path.
373
374 // And the empty-secret Drop path (the `Some` arm is skipped).
375 let empty = RuntimeContext::default();
376 drop(empty);
377 }
378
379 #[test]
380 fn install_global_is_last_writer_loses_against_lazy_init() {
381 // `install_global` is idempotent in the OnceLock sense. Because
382 // sibling tests in this binary may already have seeded the
383 // singleton via `global()`, a fresh install is silently ignored
384 // — the call must not panic regardless of prior seeding.
385 RuntimeContext::install_global(RuntimeContext::default());
386 // global() / global_arc() return a stable, non-null handle.
387 let a = RuntimeContext::global_arc();
388 let b = RuntimeContext::global_arc();
389 assert!(std::sync::Arc::ptr_eq(&a, &b));
390 // The borrowed accessor derefs the same backing Arc.
391 let r = RuntimeContext::global();
392 assert!(std::ptr::eq(r, a.as_ref()));
393 }
394}