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}