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}