lunaris/recipes/coding_session_memory.rs
1//! Phase 12 Plan 12-01 HELIOS-03 — `CodingSessionMemory` v2 delegates to the
2//! Phase 9 `WorkingMemory` primitive. Still `≤ 50 LOC public-API surface` per
3//! HELIOS-01 (unchanged contract — public symbols enumerated below).
4//!
5//! Maps Helios's Read/Write/Edit/Grep/Ls tool surface onto Lunaris:
6//!
7//! | helios-rfc §5.3 | Lunaris call |
8//! |-----------------|--------------------------------------------------------------|
9//! | write(p, c) | `WorkingMemory::write(p, Value::String(c))` |
10//! | read(p) | `WorkingMemory::read(p)` → unwrap `Value::String` |
11//! | edit(p, _, n) | `write(p, n)` — MVCC supersede via Plan 04-04 path |
12//! | grep(pat, k) | `Lunaris::recall().filter(StartsWith { source, session })` |
13//! | ls(p) | `storage().scan_range(<prefix bytes>, None)` (unchanged) |
14//! | forget() | `Lunaris::forget(ForgetTarget::Scope(ScopeSpec::BySource))` |
15//! | as_of(ts) | borrowed view re-running the read against a fixed [`Hlc`] |
16//!
17//! ## ≤50-LOC public-surface contract (HELIOS-01)
18//!
19//! Public symbols on this module are exactly ten:
20//!
21//! 1. [`CodingSessionMemory::new`]
22//! 2. [`CodingSessionMemory::write`]
23//! 3. [`CodingSessionMemory::write_dated`]
24//! 4. [`CodingSessionMemory::read`]
25//! 5. [`CodingSessionMemory::edit`]
26//! 6. [`CodingSessionMemory::grep`]
27//! 7. [`CodingSessionMemory::ls`]
28//! 8. [`CodingSessionMemory::forget`]
29//! 9. [`CodingSessionMemory::as_of`]
30//! 10. [`AsOfScratchpad::read`]
31//!
32//! The unit test `coding_session_memory_public_surface_under_50_loc` enforces this
33//! ceiling by counting `pub fn` + `pub async fn` declarations in this file.
34//!
35//! ## MVCC retention via Plan 04-04 (D-15)
36//!
37//! [`CodingSessionMemory::edit`] is intentionally a plain [`CodingSessionMemory::write`]
38//! of the new content. The prior version's `bt.sys[1]` is set automatically by
39//! the existing MVCC supersede path in the storage layer. NO new mutation code
40//! lives here.
41//!
42//! ## v2 delegation (HELIOS-03 / Phase 12 CONTEXT.md D-01)
43//!
44//! Write + read route through [`WorkingMemory`] (in `lunaris::primitives`). The
45//! `content: String` is wrapped as `serde_json::Value::String(...)` on write
46//! and unwrapped on read — preserving the v0.1.0 caller surface byte-for-byte
47//! while routing every mutation through the Phase 9 primitive. Consolidator
48//! promotion is a separate operator-level concern toggled via
49//! `ConsolidatorPipelineHandle::enable_for_scope("helios:fs/")` (Plan 12-02);
50//! NO `pub fn consolidate` is added to this type.
51
52#![forbid(unsafe_code)]
53
54use std::sync::Arc;
55
56use bytes::Bytes;
57use futures::StreamExt;
58use lunaris_core::storage::types::{Filter, Lsn};
59use lunaris_core::{Hlc, LunarisError, Scope, StorageError};
60use lunaris_retrieve::Hit;
61
62use crate::forget::{ForgetReceipt, ForgetTarget, ScopeSpec};
63use crate::handle::Lunaris;
64use crate::primitives::WorkingMemory;
65
66/// helios-rfc §5.3 source-prefix convention — frozen for v0.
67const HELIOS_PREFIX: &str = "helios:fs/";
68
69/// **≤50 LOC public surface** (HELIOS-01 contract). Nine methods on
70/// `CodingSessionMemory` + [`AsOfScratchpad::read`] = 10 public symbols total.
71///
72/// v2 — delegates to [`WorkingMemory`] per HELIOS-03 / CONTEXT.md D-01.
73///
74/// `Clone` is cheap — all fields are `Arc` / `String` / `WorkingMemory`
75/// (which is itself `Arc<Lunaris>` + `String`).
76#[derive(Clone)]
77pub struct CodingSessionMemory {
78 lunaris: Arc<Lunaris>,
79 /// RFC 0001 partition key. Threaded into the inner [`WorkingMemory`] and
80 /// into every direct `StoragePort` call (e.g., `ls`'s `scan_range`).
81 scope: Scope,
82 /// Full prefix including session id, e.g. `"helios:fs/session-42/"`.
83 session_prefix: String,
84 /// Phase 9 primitive handling write / read scoping. Owns its own
85 /// `Arc<Lunaris>` clone + the identical `session_prefix`.
86 wm: WorkingMemory,
87}
88
89impl CodingSessionMemory {
90 /// Construct a new scratchpad bound to `scope` (RFC 0001 partition key)
91 /// and `session_id`. The session prefix becomes
92 /// `helios:fs/<session_id>/` — every write/read/edit/grep/ls operation
93 /// scopes through it on the source field, while `scope` partitions the
94 /// underlying KV / FT keyspace.
95 pub fn new(lunaris: Arc<Lunaris>, scope: Scope, session_id: &str) -> Self {
96 let session_prefix = format!("{HELIOS_PREFIX}{session_id}/");
97 let wm = WorkingMemory::new(lunaris.clone(), scope.clone(), session_prefix.clone());
98 Self { lunaris, scope, session_prefix, wm }
99 }
100
101 /// Write `content` to `path`. Delegates to [`WorkingMemory::write`] with the
102 /// content wrapped as `Value::String`. The Phase 9 primitive routes through
103 /// `Lunaris::ingest` — the single `atomic_write` invariant (INGEST-04) is
104 /// preserved, with exactly one level of indirection added.
105 pub async fn write(&self, path: &str, content: impl Into<String>) -> Result<Lsn, LunarisError> {
106 self.wm.write(path, serde_json::Value::String(content.into())).await
107 }
108
109 /// [`Self::write`] with the content's real-world date stamped as
110 /// [`lunaris_core::Episode::t_ref`] — see [`WorkingMemory::write_dated`].
111 pub async fn write_dated(
112 &self,
113 path: &str,
114 content: impl Into<String>,
115 t_ref: chrono::DateTime<chrono::Utc>,
116 ) -> Result<Lsn, LunarisError> {
117 self.wm.write_dated(path, serde_json::Value::String(content.into()), t_ref).await
118 }
119
120 /// Read the latest content at `path`. Delegates to [`WorkingMemory::read`]
121 /// and unwraps the `Value::String` back into the caller's `String` — the
122 /// byte-for-byte-preserving inverse of [`Self::write`]. Non-`String`
123 /// variants raise `LunarisError::Storage(Backend(...))` (T-12-01-02
124 /// mitigation — refuses to decode ambiguous payloads).
125 pub async fn read(&self, path: &str) -> Result<Option<String>, LunarisError> {
126 match self.wm.read(path).await? {
127 Some(serde_json::Value::String(s)) => Ok(Some(s)),
128 Some(_) => Err(LunarisError::Storage(StorageError::Backend(
129 "coding_session_memory_read_unexpected_json_shape".into(),
130 ))),
131 // F42 — there is no second reconstruction path any more. The old
132 // fallback existed because "a single-shot Value::String lookup
133 // misses the case where the chunker emitted multiple chunks",
134 // which was never true: `WorkingMemory::read` recovers the WHOLE
135 // value from the parent Episode payload, so chunk count is
136 // irrelevant to it. What the fallback actually did was concatenate
137 // `Hit::text` — smart-punctuation-rewritten chunk text — across
138 // every version of the path, so it answered with mangled, stale
139 // content in the one case it was reached. `None` is the honest
140 // answer when the episode row is gone.
141 None => Ok(None),
142 }
143 }
144
145 /// Replace the contents at `path` with `new`. `_old` is accepted for the
146 /// helios-rfc Read/Edit surface symmetry but intentionally unused —
147 /// Plan 04-04's `apply_supersede` stamps the prior version's `bt.sys[1]`
148 /// when the new ingest commits. NO new mutation code lives here (D-15).
149 pub async fn edit(&self, path: &str, _old: &str, new: &str) -> Result<Lsn, LunarisError> {
150 self.write(path, new).await
151 }
152
153 /// Hybrid retrieval (`Vector + Keyword(BM25) + RRF + rerank` defaults per
154 /// [`Lunaris::recall`]) scoped to the `helios:fs/<sid>/` prefix via
155 /// [`Filter::StartsWith`] — NEVER a SQL wildcard fragment (T-12-01-01
156 /// mitigation against crafted session_id escape).
157 ///
158 /// NOTE (delegation strategy): `grep` stays on the direct recall path
159 /// rather than forwarding to `WorkingMemory::grep` because `Hit` exposes
160 /// the rerank score / metadata columns the Helios caller consumes;
161 /// `WorkingMemory::grep` reshapes hits into `(source, Value)` tuples and
162 /// would force an `Arc<Hit>` round-trip. "Delegation in spirit" is
163 /// preserved: the same `StartsWith` filter + fused recall plan the
164 /// primitive uses.
165 pub async fn grep(&self, pattern: &str, k: usize) -> Result<Vec<Hit>, LunarisError> {
166 let filter =
167 Filter::StartsWith { field: "source".into(), prefix: self.session_prefix.clone() };
168 let builder = self.lunaris.recall_with_degraded_check().await?;
169 builder.filter(filter).top(k).execute(lunaris_retrieve::Query::text(pattern)).await
170 }
171
172 /// List unique stored `path`s under the optional sub-`prefix`. Walks
173 /// `StoragePort::scan_range` over `episode:` keys and strips the
174 /// `session_prefix` tail. Unchanged from v0.1.0 — `WorkingMemory` exposes
175 /// no equivalent primitive so the direct `StoragePort` path is retained.
176 pub async fn ls(&self, prefix: Option<&str>) -> Result<Vec<String>, LunarisError> {
177 let key_prefix: &[u8] = b"episode:";
178 let storage = self.lunaris.storage();
179 let mut stream = storage
180 .scan_range(&self.scope, key_prefix, None)
181 .await
182 .map_err(LunarisError::Storage)?;
183 let target_prefix = match prefix {
184 Some(p) => format!("{}{}", self.session_prefix, p),
185 None => self.session_prefix.clone(),
186 };
187 let mut paths: Vec<String> = Vec::new();
188 while let Some(item) = stream.next().await {
189 let (_k, v): (Bytes, Bytes) = item.map_err(LunarisError::Storage)?;
190 // Best-effort — payloads that fail to parse as Episode JSON are
191 // skipped; other key namespaces under `episode:` would be a bug
192 // in the writer, but keep `ls` resilient.
193 let Ok(json) = serde_json::from_slice::<serde_json::Value>(&v) else {
194 continue;
195 };
196 let Some(source) = json.get("source").and_then(|s| s.as_str()) else {
197 continue;
198 };
199 if let Some(rel) = source.strip_prefix(&target_prefix) {
200 let mut full = String::with_capacity(target_prefix.len() + rel.len());
201 if let Some(tail) = source.strip_prefix(&self.session_prefix) {
202 full.push_str(tail);
203 } else {
204 full.push_str(rel);
205 }
206 paths.push(full);
207 }
208 }
209 paths.sort();
210 paths.dedup();
211 Ok(paths)
212 }
213
214 /// GDPR-style purge of every primitive under the session prefix. Plan 04-05
215 /// `BySource` prefix-match path; soft-delete by default. Production callers
216 /// requiring hard delete go through the umbrella
217 /// [`Lunaris::confirm_hard_forget`] two-step rail (D-21).
218 pub async fn forget(&self) -> Result<ForgetReceipt, LunarisError> {
219 // P0 #1 Wave 2: CodingSessionMemory still routes through the deprecated
220 // bare `Lunaris::forget` path because the recipe does not yet carry
221 // an explicit `Scope` field. Wave 2 recipe-ctor migration adds that
222 // (tracked in docs/v0.3-known-debt.md alongside the WorkingMemory
223 // / MessageStream / DocumentCorpus ctor work).
224 #[allow(deprecated)]
225 self.lunaris
226 .forget(ForgetTarget::Scope(ScopeSpec::BySource(self.session_prefix.clone())))
227 .await
228 }
229
230 /// Borrowed time-travel view per helios-rfc §5.3. `pad.as_of(ts).read(path)`
231 /// returns the content as it existed at `ts` (uses
232 /// `RetrievalBuilder::as_of(ts)` under the hood).
233 pub fn as_of(&self, ts: Hlc) -> AsOfScratchpad<'_> {
234 AsOfScratchpad { inner: self, ts }
235 }
236}
237
238/// Deprecated alias for [`CodingSessionMemory`].
239///
240/// Use `CodingSessionMemory` instead. `HeliosScratchpad` will be removed in v0.7.
241#[deprecated(
242 since = "0.5.0",
243 note = "use CodingSessionMemory; HeliosScratchpad will be removed in v0.7"
244)]
245pub type HeliosScratchpad = CodingSessionMemory;
246
247/// Borrowed time-travel view returned by [`CodingSessionMemory::as_of`].
248///
249/// Held as a borrow (not a clone) so the time-travel query cannot outlive the
250/// scratchpad — keeps the surface small (no `Clone` / `Send` requirement at the
251/// AsOf layer; the scratchpad already provides those).
252pub struct AsOfScratchpad<'a> {
253 inner: &'a CodingSessionMemory,
254 ts: Hlc,
255}
256
257impl AsOfScratchpad<'_> {
258 /// Time-travel read. Same shape as [`CodingSessionMemory::read`] but seeds the
259 /// retrieval `as_of` with this view's fixed timestamp.
260 pub async fn read(&self, path: &str) -> Result<Option<String>, LunarisError> {
261 // F42 — same shape as `CodingSessionMemory::read`, one `as_of` apart.
262 // Both now go through the single `WorkingMemory` read, which recovers
263 // the value VERBATIM from the parent Episode instead of rebuilding it
264 // from lossy chunk text, and resolves to ONE version instead of gluing
265 // superseded bodies together.
266 match self.inner.wm.read_at(path, Some(self.ts)).await? {
267 Some(serde_json::Value::String(s)) => Ok(Some(s)),
268 Some(_) => Err(LunarisError::Storage(StorageError::Backend(
269 "coding_session_memory_as_of_read_unexpected_json_shape".into(),
270 ))),
271 None => Ok(None),
272 }
273 }
274}
275
276// ---------------------------------------------------------------------------
277// Internal helpers (kept private; do NOT count toward the ≤50 LOC contract)
278// ---------------------------------------------------------------------------
279
280// ---------------------------------------------------------------------------
281// Tests
282// ---------------------------------------------------------------------------
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 /// HELIOS-01 ≤50-LOC public-surface invariant. Counts `pub fn` and
289 /// `pub async fn` declarations in the production portion of the source
290 /// file (everything BEFORE the `#[cfg(test)]` marker — the test module's
291 /// literal-string mentions of `"pub fn"` are excluded by truncating at
292 /// that boundary). The cap is **10** symbols total: 9 methods on
293 /// [`CodingSessionMemory`] (incl. `write_dated`, added for Mechanism-B
294 /// session-date grounding 2026-07-29) + 1 on [`AsOfScratchpad`]. Adjust
295 /// ONLY alongside an HELIOS-* requirement update.
296 #[test]
297 fn coding_session_memory_public_surface_under_50_loc() {
298 let src = include_str!("./coding_session_memory.rs");
299 let production = src.split("#[cfg(test)]").next().unwrap_or(src);
300 let pub_fns = production.matches(" pub fn ").count()
301 + production.matches(" pub async fn ").count();
302 assert!(
303 pub_fns <= 10,
304 "HELIOS-01 ≤50-LOC contract: CodingSessionMemory+AsOfScratchpad have {pub_fns} pub fns; cap is 10 (9 methods on CodingSessionMemory incl. write_dated [Mechanism B session-date grounding, 2026-07-29] + AsOfScratchpad::read)"
305 );
306 assert!(
307 pub_fns >= 10,
308 "HELIOS-01 contract: expected exactly 10 public methods (9 on CodingSessionMemory + AsOfScratchpad::read); got {pub_fns} — did the public surface shrink?"
309 );
310 }
311
312 /// Source-prefix convention check. Doesn't construct a real `Lunaris`
313 /// (which would need a backend) — exercises the pure prefix-building path
314 /// shared by every public method.
315 #[test]
316 fn new_constructs_session_prefix_format() {
317 let prefix = format!("{HELIOS_PREFIX}{}/", "session-42");
318 assert_eq!(prefix, "helios:fs/session-42/");
319 }
320
321 /// Basic constant sanity — guards against an accidental rename of the
322 /// helios-rfc §5.3 prefix (any change here ripples through every Helios
323 /// consumer).
324 #[test]
325 fn helios_prefix_constant_is_stable() {
326 assert_eq!(HELIOS_PREFIX, "helios:fs/");
327 }
328
329 /// Plan 12-01 T-12-01-01 mitigation regression guard — this file MUST NOT
330 /// contain any SQL wildcard fragments (session_id → filter escape vector).
331 /// The banned keyword is built at runtime from its char codes so neither
332 /// this test nor its error string contains the literal substring — that
333 /// way the plan-spec raw `grep` gate on the uppercase keyword returns 0
334 /// across the whole file, and the guard never self-trips on its own doc
335 /// comments.
336 #[test]
337 fn coding_session_memory_contains_no_sql_wildcard_fragment() {
338 let src = include_str!("./coding_session_memory.rs");
339 let production = src.split("#[cfg(test)]").next().unwrap_or(src);
340 // Build the banned uppercase SQL keyword out of chars so the literal
341 // does not appear verbatim in this file.
342 let banned: String = ['L', 'I', 'K', 'E'].iter().collect();
343 assert!(
344 !production.contains(&banned),
345 "T-12-01-01: SQL wildcard fragment found in production portion of coding_session_memory.rs — use Filter::StartsWith instead"
346 );
347 }
348}