Skip to main content

praxis_policy_apl_runtime/
session_store.rs

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