Skip to main content

apl_cpex/
session_store.rs

1// Location: ./crates/apl-cpex/src/session_store.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `SessionStore` — pluggable backend for cross-request session state.
7// v0 surface is intentionally tiny: monotonic label append + load. That
8// covers `extensions.security.labels` persistence, which is the only
9// session-scoped state APL needs today.
10//
11// # Why a trait
12//
13// State that survives between requests in the same session (accumulated
14// taint labels, delegation history, conversation context) needs to be
15// pluggable: in-memory for tests and single-process deployments, Redis
16// or DynamoDB for distributed ones. The previous Python implementation
17// had a `SessionState` abstraction with the same shape; this is the
18// Rust port. Only the labels surface lands in v0 — delegation hops,
19// conversation history, and arbitrary KV come when their consumers do.
20//
21// # String-typed deliberately
22//
23// The trait stays string-typed (`Vec<String>` for labels) rather than
24// reaching into cpex-core's `MonotonicSet<String>` so non-CMF bridges
25// (future apl-mcp, apl-langgraph, etc.) can reuse it without dragging
26// CPEX types into their surface. `CmfPluginInvoker` does the
27// hydration/persistence into/out of `Extensions.security.labels`.
28
29use std::collections::{HashMap, HashSet};
30use std::sync::{Arc, RwLock};
31
32use async_trait::async_trait;
33
34/// Error returned by a `SessionStore` when the backing store could not
35/// satisfy a request. Distributed backends (e.g. Valkey) surface
36/// connectivity/timeout/protocol failures and undecodable responses
37/// here so callers can **fail closed** rather than silently treating a
38/// backend failure as "no accumulated labels".
39///
40/// String-typed deliberately, matching the trait's own philosophy (see
41/// the module header): the error stays free of backend-specific types so
42/// non-CMF bridges and the cross-crate `cpex-session-valkey` backend can
43/// construct it without dragging dependencies into this surface.
44///
45/// Note the distinction this enables: a **positively-confirmed key-miss**
46/// (unknown session) is `Ok(empty)`, NOT an error — only a genuine
47/// backend failure is an `Err`.
48#[derive(Debug, thiserror::Error)]
49pub enum SessionStoreError {
50    /// The backing store was unreachable, timed out, returned an error,
51    /// or returned a response that could not be decoded into the
52    /// expected representation. Callers fail closed on this.
53    #[error("session store backend error: {0}")]
54    Backend(String),
55}
56
57/// Pluggable session-state backend. Implementations must be `Send + Sync`
58/// — the same store is shared across all concurrent requests.
59///
60/// Invariants:
61/// - `append_labels` is **monotonic** — labels added to a session never
62///   come back out. Removal (declassification) is a separate operation
63///   not covered by v0.
64/// - `load_labels` for an unknown `session_id` returns `Ok(empty)` — a
65///   positively-confirmed key-miss is the right response for non-session
66///   traffic, and is distinct from a backend failure (`Err`).
67/// - Both methods return `Result` so a distributed backend can propagate
68///   failures and the caller can fail the request closed. The in-process
69///   [`MemorySessionStore`] is infallible and always returns `Ok`.
70#[async_trait]
71pub trait SessionStore: Send + Sync {
72    /// Load the union of labels accumulated for the session. `Ok(empty)`
73    /// for new or unknown sessions (a confirmed key-miss); `Err` only on
74    /// a backend failure.
75    async fn load_labels(&self, session_id: &str) -> Result<Vec<String>, SessionStoreError>;
76
77    /// Append labels to the session. Existing labels are kept; new ones
78    /// are unioned in. Caller has already deduped against `load_labels`
79    /// in the hot path, but the store re-dedups defensively. `Err` only
80    /// on a backend failure.
81    async fn append_labels(
82        &self,
83        session_id: &str,
84        labels: &[String],
85    ) -> Result<(), SessionStoreError>;
86}
87
88/// Factory the visitor consults when it encounters a
89/// `global.apl.session_store` block in the unified config. Mirrors
90/// [`apl_core::step::PdpFactory`]: each factory advertises a `kind()`
91/// string matching the YAML block's `kind:` field, and `build` turns the
92/// block into a live store. Registered up front via
93/// [`crate::AplOptions::session_store_factories`]; the visitor selects
94/// the active store from config during its global-config walk, before
95/// any route handler captures the store.
96///
97/// `build` errors are construction-time (bad config, unresolvable
98/// endpoint) and surface as a config-load failure — distinct from the
99/// request-time [`SessionStoreError`] the trait methods return.
100pub trait SessionStoreFactory: Send + Sync {
101    /// The `kind:` discriminator this factory builds (e.g. `"valkey"`).
102    fn kind(&self) -> &str;
103
104    /// Build a store from its config block. The whole
105    /// `global.apl.session_store` mapping is passed so the factory can
106    /// read its own keys (endpoint, TLS, auth, prefix, TTL, …).
107    fn build(
108        &self,
109        config: &serde_yaml::Value,
110    ) -> Result<Arc<dyn SessionStore>, Box<dyn std::error::Error + Send + Sync>>;
111}
112
113/// In-process `SessionStore` backed by a `HashMap` of `HashSet`s. Suitable
114/// for tests, single-process deployments, and as the default when no
115/// distributed store is configured. Cloning the store via `Arc` shares
116/// state across all consumers.
117#[derive(Default)]
118pub struct MemorySessionStore {
119    /// `RwLock` because reads (load_labels at request start) outnumber
120    /// writes (append at request end) in steady state — and lock
121    /// contention is bounded by the per-session level of concurrency,
122    /// not request volume.
123    inner: RwLock<HashMap<String, HashSet<String>>>,
124}
125
126impl MemorySessionStore {
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Snapshot the entire store. Test/diagnostic helper — production
132    /// callers should go through the trait so the backing implementation
133    /// stays swappable.
134    pub fn snapshot(&self) -> HashMap<String, HashSet<String>> {
135        self.inner.read().unwrap_or_else(|p| p.into_inner()).clone()
136    }
137}
138
139#[async_trait]
140impl SessionStore for MemorySessionStore {
141    async fn load_labels(&self, session_id: &str) -> Result<Vec<String>, SessionStoreError> {
142        let r = self.inner.read().unwrap_or_else(|p| p.into_inner());
143        Ok(r.get(session_id)
144            .map(|s| s.iter().cloned().collect())
145            .unwrap_or_default())
146    }
147
148    async fn append_labels(
149        &self,
150        session_id: &str,
151        labels: &[String],
152    ) -> Result<(), SessionStoreError> {
153        if labels.is_empty() {
154            return Ok(());
155        }
156        let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner());
157        let entry = w.entry(session_id.to_string()).or_default();
158        for l in labels {
159            entry.insert(l.clone());
160        }
161        Ok(())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::sync::Arc;
169
170    #[tokio::test]
171    async fn load_for_unknown_session_is_empty() {
172        let store = MemorySessionStore::new();
173        // Unknown session is a confirmed key-miss: Ok(empty), not Err.
174        assert!(store.load_labels("nonexistent").await.unwrap().is_empty());
175    }
176
177    #[tokio::test]
178    async fn append_then_load_roundtrips() {
179        let store = MemorySessionStore::new();
180        store
181            .append_labels("sess-1", &["PII".to_string(), "INTERNAL".to_string()])
182            .await
183            .unwrap();
184        let mut labels = store.load_labels("sess-1").await.unwrap();
185        labels.sort();
186        assert_eq!(labels, vec!["INTERNAL".to_string(), "PII".to_string()]);
187    }
188
189    #[tokio::test]
190    async fn append_is_monotonic_dedupes() {
191        let store = MemorySessionStore::new();
192        store
193            .append_labels("sess-1", &["PII".to_string()])
194            .await
195            .unwrap();
196        store
197            .append_labels("sess-1", &["PII".to_string(), "PII".to_string()])
198            .await
199            .unwrap();
200        let labels = store.load_labels("sess-1").await.unwrap();
201        assert_eq!(labels.len(), 1);
202        assert_eq!(labels[0], "PII");
203    }
204
205    #[tokio::test]
206    async fn sessions_are_isolated() {
207        let store = MemorySessionStore::new();
208        store.append_labels("a", &["X".to_string()]).await.unwrap();
209        store.append_labels("b", &["Y".to_string()]).await.unwrap();
210        assert_eq!(store.load_labels("a").await.unwrap(), vec!["X".to_string()]);
211        assert_eq!(store.load_labels("b").await.unwrap(), vec!["Y".to_string()]);
212    }
213
214    #[tokio::test]
215    async fn shared_arc_observes_writes() {
216        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
217        let c1 = Arc::clone(&store);
218        let c2 = Arc::clone(&store);
219        c1.append_labels("sess", &["Z".to_string()]).await.unwrap();
220        assert_eq!(c2.load_labels("sess").await.unwrap(), vec!["Z".to_string()]);
221    }
222}