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    /// #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}