Skip to main content

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}