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
103impl std::fmt::Debug for RuntimeContext {
104 /// #1262 — redact `hooks_hmac_secret` so accidental `{:?}` prints
105 /// of the runtime context never leak the HMAC signing key. The
106 /// remaining fields are non-secret and use their derived Debug.
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 let secret_present = self
109 .hooks_hmac_secret
110 .read()
111 .map(|g| g.is_some())
112 .unwrap_or(false);
113 f.debug_struct("RuntimeContext")
114 .field(
115 "hooks_hmac_secret",
116 &if secret_present {
117 crate::REDACTED_PLACEHOLDER
118 } else {
119 "<unset>"
120 },
121 )
122 .field("max_decompressed_bytes", &self.max_decompressed_bytes)
123 .field("audit", &self.audit)
124 .field("recall_tracker", &"<recall_tracker>")
125 .field("keypair_cache", &"<keypair_cache>")
126 .finish()
127 }
128}
129
130impl Drop for RuntimeContext {
131 /// #1258 — zeroize the resolved `hooks_hmac_secret` (if any) on
132 /// scope exit so the HMAC signing key does not linger on the heap
133 /// past the runtime context's lifetime.
134 fn drop(&mut self) {
135 if let Ok(mut guard) = self.hooks_hmac_secret.write() {
136 if let Some(secret) = guard.as_mut() {
137 use zeroize::Zeroize;
138 secret.zeroize();
139 }
140 }
141 }
142}
143
144/// V-4 audit chain state. Owns the same `(sink, sequence)` pair the
145/// pre-#1192 `src/audit.rs` module-level statics owned; the public
146/// `crate::audit::*` functions delegate here.
147///
148/// The chain invariants this struct preserves byte-for-byte:
149///
150/// 1. `sink` is wrapped in `RwLock<Option<Arc<AuditSink>>>` so the
151/// `init` path can swap the sink atomically and `emit` can clone the
152/// `Arc` without holding the lock for the file write.
153/// 2. `sequence` is an `AtomicU64` so concurrent emit threads agree on
154/// a monotonic counter; it is seeded from the trailing record's
155/// sequence on `init` (F2 fix — sequence survives daemon restart).
156/// 3. The `AuditSink::inner` mutex serialises the hash-chain head
157/// update + line write, so the chain is consistent across producer
158/// threads.
159#[derive(Debug, Default)]
160pub struct AuditState {
161 /// Process-wide audit sink. `None` when audit is disabled. Wrapped
162 /// in `RwLock` (rather than `OnceLock`) so tests can swap in an
163 /// in-memory sink between cases without leaking state across runs.
164 pub sink: RwLock<Option<Arc<crate::audit::AuditSink>>>,
165 /// Per-process monotonic sequence counter. Starts at 0; first emit
166 /// produces sequence 1. `init` reseeds from the trailing record's
167 /// sequence so `audit verify` doesn't trip on a restart-induced
168 /// reset (F2 round-2 fix).
169 pub sequence: AtomicU64,
170}
171
172// ---------------------------------------------------------------------------
173// Process-wide singleton
174// ---------------------------------------------------------------------------
175
176/// Process-wide [`RuntimeContext`] handle. Seeded on first
177/// [`global()`] call (lazy-init via [`RuntimeContext::default`]) or
178/// explicitly installed at boot via [`install_global`].
179///
180/// Stored as `Arc<RuntimeContext>` so callers can either borrow the
181/// singleton via [`RuntimeContext::global`] (free) or clone the `Arc`
182/// via [`RuntimeContext::global_arc`] when they need to keep a typed
183/// handle on a struct field (e.g. `AppState::runtime`). Arc clones are
184/// cheap (refcount increment) so neither posture pays an allocation.
185static GLOBAL: OnceLock<Arc<RuntimeContext>> = OnceLock::new();
186
187impl RuntimeContext {
188 /// Install a custom [`RuntimeContext`] as the process-wide singleton.
189 /// Idempotent in the same sense as `OnceLock::set` — the first
190 /// install wins; subsequent calls are silently ignored (the
191 /// returned `Result` is suppressed to keep the boot path
192 /// infallible against an accidental double-install).
193 ///
194 /// Boot code typically does NOT call this — the lazy-init in
195 /// [`global()`] is sufficient, and the legacy `set_*` accessors
196 /// (`crate::config::set_active_hooks_hmac_secret` etc.) populate
197 /// the inner fields via interior mutability. The hook exists for
198 /// the rare test that wants to pin a non-default starting state.
199 pub fn install_global(ctx: RuntimeContext) {
200 // Drop the Result — last-writer-loses matches the prior
201 // `OnceLock::set` posture used by the per-static
202 // `OnceLock::get_or_init` calls this struct replaced.
203 let _ = GLOBAL.set(Arc::new(ctx));
204 }
205
206 /// Return a borrowed reference to the process-wide
207 /// [`RuntimeContext`]. Seeds the singleton with
208 /// [`RuntimeContext::default`] on first call so callers never see
209 /// `None` — same `get_or_init` semantics as the per-static
210 /// `OnceLock`s this struct replaced.
211 ///
212 /// The returned reference is `&'static` because the singleton
213 /// `Arc<RuntimeContext>` lives inside a process-wide `OnceLock`
214 /// that itself never drops — once seeded, the `Arc` (and the
215 /// `RuntimeContext` it owns) outlives the entire process.
216 #[must_use]
217 pub fn global() -> &'static RuntimeContext {
218 Self::global_arc_ref()
219 }
220
221 /// Internal — return a reference to the `Arc<RuntimeContext>`
222 /// stored in the singleton slot. Auto-derefed by [`global()`].
223 fn global_arc_ref() -> &'static Arc<RuntimeContext> {
224 GLOBAL.get_or_init(|| Arc::new(RuntimeContext::default()))
225 }
226
227 /// Return a cloned `Arc<RuntimeContext>` to the process-wide
228 /// singleton. Cheap (refcount increment, no allocation). Used by
229 /// long-lived runtime structs (notably `AppState::runtime`) that
230 /// want to keep a typed handle on a field rather than re-grabbing
231 /// the global via [`RuntimeContext::global`] on every access.
232 #[must_use]
233 pub fn global_arc() -> Arc<RuntimeContext> {
234 Arc::clone(Self::global_arc_ref())
235 }
236}
237
238// ---------------------------------------------------------------------------
239// Tests
240// ---------------------------------------------------------------------------
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn runtime_context_default_is_constructible() {
248 // Pin: every field has a sensible default that lets unit
249 // tests exercise the struct without booting the daemon.
250 let ctx = RuntimeContext::default();
251 assert!(ctx.hooks_hmac_secret.read().unwrap().is_none());
252 assert!(ctx.max_decompressed_bytes.read().unwrap().is_none());
253 assert!(ctx.audit.sink.read().unwrap().is_none());
254 assert_eq!(
255 ctx.audit.sequence.load(std::sync::atomic::Ordering::SeqCst),
256 0
257 );
258 assert_eq!(ctx.recall_tracker.session_count(), 0);
259 assert_eq!(ctx.keypair_cache.lock().unwrap().len(), 0);
260 }
261
262 #[test]
263 fn runtime_context_global_returns_stable_handle() {
264 // Pin: two reads of `global()` from the same process MUST
265 // return the same backing struct so the legacy free-fn
266 // surface keeps observing the same mutations.
267 let a = RuntimeContext::global() as *const RuntimeContext;
268 let b = RuntimeContext::global() as *const RuntimeContext;
269 assert_eq!(a, b, "global() must return a stable reference");
270 }
271
272 #[test]
273 fn runtime_context_audit_state_default() {
274 // Pin: AuditState defaults match the prior module-level
275 // `static SINK: RwLock::new(None)` + `static SEQUENCE:
276 // AtomicU64::new(0)` shape.
277 let audit = AuditState::default();
278 assert!(audit.sink.read().unwrap().is_none());
279 assert_eq!(audit.sequence.load(std::sync::atomic::Ordering::SeqCst), 0);
280 }
281
282 #[test]
283 fn debug_impl_redacts_secret_when_present() {
284 // #1262 — the manual Debug impl must never leak the HMAC bytes.
285 let ctx = RuntimeContext::default();
286 *ctx.hooks_hmac_secret.write().unwrap() = Some("super-secret-hmac-key".to_string());
287 let rendered = format!("{ctx:?}");
288 assert!(
289 !rendered.contains("super-secret-hmac-key"),
290 "Debug leaked the raw HMAC secret: {rendered}"
291 );
292 assert!(rendered.contains(crate::REDACTED_PLACEHOLDER));
293 assert!(rendered.contains("RuntimeContext"));
294 }
295
296 #[test]
297 fn debug_impl_marks_unset_secret() {
298 // The other Debug branch — `<unset>` when no secret is present.
299 let ctx = RuntimeContext::default();
300 let rendered = format!("{ctx:?}");
301 assert!(rendered.contains("<unset>"), "got: {rendered}");
302 assert!(rendered.contains("<recall_tracker>"));
303 assert!(rendered.contains("<keypair_cache>"));
304 }
305
306 #[test]
307 fn drop_zeroizes_secret_on_scope_exit() {
308 // #1258 — the Drop impl zeroizes the secret. We can't observe the
309 // freed heap directly, but we can drive the Drop body (the
310 // `if let Ok(...) { if let Some(...) }` arms) on a context that
311 // carries a secret, proving it runs without panic.
312 let ctx = RuntimeContext::default();
313 *ctx.hooks_hmac_secret.write().unwrap() = Some("to-be-zeroized".to_string());
314 drop(ctx); // exercises the Drop body's populated-secret path.
315
316 // And the empty-secret Drop path (the `Some` arm is skipped).
317 let empty = RuntimeContext::default();
318 drop(empty);
319 }
320
321 #[test]
322 fn install_global_is_last_writer_loses_against_lazy_init() {
323 // `install_global` is idempotent in the OnceLock sense. Because
324 // sibling tests in this binary may already have seeded the
325 // singleton via `global()`, a fresh install is silently ignored
326 // — the call must not panic regardless of prior seeding.
327 RuntimeContext::install_global(RuntimeContext::default());
328 // global() / global_arc() return a stable, non-null handle.
329 let a = RuntimeContext::global_arc();
330 let b = RuntimeContext::global_arc();
331 assert!(std::sync::Arc::ptr_eq(&a, &b));
332 // The borrowed accessor derefs the same backing Arc.
333 let r = RuntimeContext::global();
334 assert!(std::ptr::eq(r, a.as_ref()));
335 }
336}