Skip to main content

harn_session_store/
sqlite.rs

1//! SQLite-backed [`SessionStore`].
2//!
3//! Single-file durable backend suitable for self-hosted deployments and
4//! the TUI's persistent session DB. Schema versioning is intentionally
5//! minimal — one `schema_version` table; future migrations bump the
6//! version and run guarded ALTERs. The Postgres backend (issue #2500)
7//! follows the same shape so consumers can swap by config.
8
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11use std::time::Duration;
12
13use async_trait::async_trait;
14use rusqlite::{params, Connection, OptionalExtension};
15use uuid::Uuid;
16
17use super::event::{
18    now_ms_and_rfc3339, AppendEvent, EventId, EventSignature, SessionEventKind, StoredEvent,
19};
20use super::signing::{
21    chain_root_fold, chain_root_hash, chain_root_init, compute_record_hash, re_anchor_events,
22    verify_event_chain,
23};
24use super::store::{
25    CreateSession, EventPage, ForkResult, ListFilter, ReadRange, SessionId, SessionMeta,
26    SessionStatus, SessionStore, Snapshot, SnapshotId, StoreError, StoreHooks, StoreResult,
27    TruncateResult, VerifyFailure, VerifyReport, MAX_READ_BATCH,
28};
29
30const SCHEMA_VERSION: i64 = 1;
31const DEFAULT_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
32
33#[derive(Clone)]
34pub struct SqliteSessionStore {
35    conn: Arc<Mutex<Connection>>,
36    hooks: Arc<StoreHooks>,
37    path: PathBuf,
38}
39
40impl SqliteSessionStore {
41    pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
42        Self::open_with_hooks(path, StoreHooks::default())
43    }
44
45    pub fn open_in_memory() -> StoreResult<Self> {
46        let conn =
47            Connection::open_in_memory().map_err(|error| StoreError::Backend(error.to_string()))?;
48        Self::initialize(conn, PathBuf::from(":memory:"), StoreHooks::default())
49    }
50
51    pub fn open_with_hooks(path: impl AsRef<Path>, hooks: StoreHooks) -> StoreResult<Self> {
52        let path = path.as_ref().to_path_buf();
53        if let Some(parent) = path.parent() {
54            if !parent.as_os_str().is_empty() {
55                std::fs::create_dir_all(parent)
56                    .map_err(|error| StoreError::Backend(error.to_string()))?;
57            }
58        }
59        let conn =
60            Connection::open(&path).map_err(|error| StoreError::Backend(error.to_string()))?;
61        Self::initialize(conn, path, hooks)
62    }
63
64    fn initialize(conn: Connection, path: PathBuf, hooks: StoreHooks) -> StoreResult<Self> {
65        conn.busy_timeout(DEFAULT_BUSY_TIMEOUT)
66            .map_err(|error| StoreError::Backend(error.to_string()))?;
67        conn.pragma_update(None, "journal_mode", "WAL")
68            .map_err(|error| StoreError::Backend(error.to_string()))?;
69        conn.pragma_update(None, "synchronous", "NORMAL")
70            .map_err(|error| StoreError::Backend(error.to_string()))?;
71        conn.execute_batch(
72            "
73            CREATE TABLE IF NOT EXISTS schema_version (
74                version INTEGER NOT NULL PRIMARY KEY
75            );
76            CREATE TABLE IF NOT EXISTS sessions (
77                id                  TEXT PRIMARY KEY,
78                tenant_id           TEXT,
79                persona             TEXT,
80                parent_session_id   TEXT,
81                created_at_ms       INTEGER NOT NULL,
82                created_at          TEXT NOT NULL,
83                updated_at_ms       INTEGER NOT NULL,
84                updated_at          TEXT NOT NULL,
85                status              TEXT NOT NULL,
86                event_count         INTEGER NOT NULL DEFAULT 0,
87                last_event_id       INTEGER,
88                chain_root_hash     TEXT,
89                closed_at_ms        INTEGER,
90                closed_at           TEXT,
91                soft_deleted_at_ms  INTEGER,
92                ttl_seconds         INTEGER,
93                tags_json           TEXT NOT NULL DEFAULT '[]',
94                attributes_json     TEXT NOT NULL DEFAULT '{}',
95                next_event_id       INTEGER NOT NULL DEFAULT 1
96            );
97            CREATE INDEX IF NOT EXISTS sessions_tenant_created
98                ON sessions(tenant_id, created_at_ms);
99            CREATE INDEX IF NOT EXISTS sessions_status
100                ON sessions(status);
101            CREATE TABLE IF NOT EXISTS session_events (
102                session_id          TEXT NOT NULL,
103                event_id            INTEGER NOT NULL,
104                tenant_id           TEXT,
105                parent_event_id     INTEGER,
106                actor               TEXT,
107                kind                TEXT NOT NULL,
108                custom_kind         TEXT,
109                payload_json        TEXT NOT NULL,
110                tags_json           TEXT NOT NULL DEFAULT '[]',
111                headers_json        TEXT NOT NULL DEFAULT '{}',
112                ts_ms               INTEGER NOT NULL,
113                ts                  TEXT NOT NULL,
114                record_hash         TEXT NOT NULL,
115                prev_hash           TEXT,
116                signature_json      TEXT,
117                PRIMARY KEY (session_id, event_id),
118                FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
119            );
120            CREATE INDEX IF NOT EXISTS session_events_ts
121                ON session_events(session_id, ts_ms);
122            CREATE TABLE IF NOT EXISTS session_tags (
123                session_id  TEXT NOT NULL,
124                tag         TEXT NOT NULL,
125                PRIMARY KEY (session_id, tag),
126                FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
127            );
128            CREATE INDEX IF NOT EXISTS session_tags_by_tag
129                ON session_tags(tag, session_id);
130            CREATE TABLE IF NOT EXISTS session_snapshots (
131                id              TEXT PRIMARY KEY,
132                session_id      TEXT NOT NULL,
133                captured_at_ms  INTEGER NOT NULL,
134                captured_at     TEXT NOT NULL,
135                body_json       TEXT NOT NULL,
136                FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
137            );
138            ",
139        )
140        .map_err(|error| StoreError::Backend(error.to_string()))?;
141        conn.execute(
142            "INSERT OR IGNORE INTO schema_version(version) VALUES (?1)",
143            params![SCHEMA_VERSION],
144        )
145        .map_err(|error| StoreError::Backend(error.to_string()))?;
146        Ok(Self {
147            conn: Arc::new(Mutex::new(conn)),
148            hooks: Arc::new(hooks),
149            path,
150        })
151    }
152
153    pub fn path(&self) -> &Path {
154        &self.path
155    }
156
157    fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
158        self.conn.lock().unwrap_or_else(|e| e.into_inner())
159    }
160}
161
162fn map_sql(error: rusqlite::Error) -> StoreError {
163    StoreError::Backend(error.to_string())
164}
165
166fn kind_to_sql(kind: &SessionEventKind) -> (String, Option<String>) {
167    match kind {
168        SessionEventKind::Custom { custom_type } => {
169            ("custom".to_string(), Some(custom_type.clone()))
170        }
171        other => (other.discriminator().to_string(), None),
172    }
173}
174
175fn kind_from_sql(kind: &str, custom_kind: Option<String>) -> StoreResult<SessionEventKind> {
176    Ok(match kind {
177        "message" => SessionEventKind::Message,
178        "tool_call" => SessionEventKind::ToolCall,
179        "tool_result" => SessionEventKind::ToolResult,
180        "plan" => SessionEventKind::Plan,
181        "compaction" => SessionEventKind::Compaction,
182        "system_reminder" => SessionEventKind::SystemReminder,
183        "hypothesis" => SessionEventKind::Hypothesis,
184        "receipt" => SessionEventKind::Receipt,
185        "reminder" => SessionEventKind::Reminder,
186        "permission_decision" => SessionEventKind::PermissionDecision,
187        "custom" => SessionEventKind::Custom {
188            custom_type: custom_kind.unwrap_or_default(),
189        },
190        other => {
191            return Err(StoreError::Backend(format!(
192                "unknown event kind '{other}' in storage"
193            )))
194        }
195    })
196}
197
198fn status_to_sql(status: SessionStatus) -> &'static str {
199    match status {
200        SessionStatus::Open => "open",
201        SessionStatus::Closed => "closed",
202        SessionStatus::SoftDeleted => "soft_deleted",
203        SessionStatus::HardDeleted => "hard_deleted",
204    }
205}
206
207fn status_from_sql(value: &str) -> StoreResult<SessionStatus> {
208    Ok(match value {
209        "open" => SessionStatus::Open,
210        "closed" => SessionStatus::Closed,
211        "soft_deleted" => SessionStatus::SoftDeleted,
212        "hard_deleted" => SessionStatus::HardDeleted,
213        other => {
214            return Err(StoreError::Backend(format!(
215                "unknown session status '{other}' in storage"
216            )))
217        }
218    })
219}
220
221fn insert_session_tags(conn: &Connection, session_id: &str, tags: &[String]) -> StoreResult<()> {
222    if tags.is_empty() {
223        return Ok(());
224    }
225    let mut stmt = conn
226        .prepare("INSERT OR IGNORE INTO session_tags (session_id, tag) VALUES (?1, ?2)")
227        .map_err(map_sql)?;
228    for tag in tags {
229        stmt.execute(params![session_id, tag]).map_err(map_sql)?;
230    }
231    Ok(())
232}
233
234fn insert_session(
235    conn: &Connection,
236    meta: &SessionMeta,
237    next_event_id: EventId,
238) -> StoreResult<()> {
239    let tags_json = serde_json::to_string(&meta.tags).unwrap_or_else(|_| "[]".into());
240    let attrs_json = serde_json::to_string(&meta.attributes).unwrap_or_else(|_| "{}".into());
241    conn.execute(
242        "INSERT INTO sessions (
243            id, tenant_id, persona, parent_session_id, created_at_ms, created_at,
244            updated_at_ms, updated_at, status, event_count, last_event_id,
245            chain_root_hash, closed_at_ms, closed_at, soft_deleted_at_ms,
246            ttl_seconds, tags_json, attributes_json, next_event_id
247         ) VALUES (
248            ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
249            ?15, ?16, ?17, ?18, ?19
250         )",
251        params![
252            meta.id,
253            meta.tenant_id,
254            meta.persona,
255            meta.parent_session_id,
256            meta.created_at_ms,
257            meta.created_at,
258            meta.updated_at_ms,
259            meta.updated_at,
260            status_to_sql(meta.status),
261            meta.event_count as i64,
262            meta.last_event_id.map(|value| value as i64),
263            meta.chain_root_hash,
264            meta.closed_at_ms,
265            meta.closed_at,
266            meta.soft_deleted_at_ms,
267            meta.ttl_seconds.map(|value| value as i64),
268            tags_json,
269            attrs_json,
270            next_event_id as i64,
271        ],
272    )
273    .map_err(map_sql)?;
274    insert_session_tags(conn, &meta.id, &meta.tags)?;
275    Ok(())
276}
277
278fn read_session_meta(conn: &Connection, session_id: &str) -> StoreResult<(SessionMeta, EventId)> {
279    let row = conn
280        .query_row(
281            "SELECT tenant_id, persona, parent_session_id, created_at_ms, created_at,
282                    updated_at_ms, updated_at, status, event_count, last_event_id,
283                    chain_root_hash, closed_at_ms, closed_at, soft_deleted_at_ms,
284                    ttl_seconds, tags_json, attributes_json, next_event_id
285             FROM sessions WHERE id = ?1",
286            params![session_id],
287            |row| {
288                Ok((
289                    row.get::<_, Option<String>>(0)?,
290                    row.get::<_, Option<String>>(1)?,
291                    row.get::<_, Option<String>>(2)?,
292                    row.get::<_, i64>(3)?,
293                    row.get::<_, String>(4)?,
294                    row.get::<_, i64>(5)?,
295                    row.get::<_, String>(6)?,
296                    row.get::<_, String>(7)?,
297                    row.get::<_, i64>(8)?,
298                    row.get::<_, Option<i64>>(9)?,
299                    row.get::<_, Option<String>>(10)?,
300                    row.get::<_, Option<i64>>(11)?,
301                    row.get::<_, Option<String>>(12)?,
302                    row.get::<_, Option<i64>>(13)?,
303                    row.get::<_, Option<i64>>(14)?,
304                    row.get::<_, String>(15)?,
305                    row.get::<_, String>(16)?,
306                    row.get::<_, i64>(17)?,
307                ))
308            },
309        )
310        .optional()
311        .map_err(map_sql)?
312        .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
313    let (
314        tenant_id,
315        persona,
316        parent_session_id,
317        created_at_ms,
318        created_at,
319        updated_at_ms,
320        updated_at,
321        status,
322        event_count,
323        last_event_id,
324        chain_root_hash,
325        closed_at_ms,
326        closed_at,
327        soft_deleted_at_ms,
328        ttl_seconds,
329        tags_json,
330        attrs_json,
331        next_event_id,
332    ) = row;
333    let tags = serde_json::from_str(&tags_json).unwrap_or_default();
334    let attributes = serde_json::from_str(&attrs_json).unwrap_or_default();
335    let meta = SessionMeta {
336        id: session_id.to_string(),
337        tenant_id,
338        persona,
339        parent_session_id,
340        created_at_ms,
341        created_at,
342        updated_at_ms,
343        updated_at,
344        status: status_from_sql(&status)?,
345        event_count: event_count as usize,
346        last_event_id: last_event_id.map(|value| value as EventId),
347        chain_root_hash,
348        closed_at_ms,
349        closed_at,
350        soft_deleted_at_ms,
351        ttl_seconds: ttl_seconds.map(|value| value as u64),
352        tags,
353        attributes,
354    };
355    Ok((meta, next_event_id as EventId))
356}
357
358fn insert_event(conn: &Connection, event: &StoredEvent) -> StoreResult<()> {
359    let (kind, custom_kind) = kind_to_sql(&event.kind);
360    let payload_json = serde_json::to_string(&event.payload)
361        .map_err(|error| StoreError::Backend(error.to_string()))?;
362    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".into());
363    let headers_json = serde_json::to_string(&event.headers).unwrap_or_else(|_| "{}".into());
364    let signature_json = event
365        .signed_by
366        .as_ref()
367        .map(|sig| serde_json::to_string(sig).unwrap_or_else(|_| "null".into()));
368    conn.execute(
369        "INSERT INTO session_events (
370            session_id, event_id, tenant_id, parent_event_id, actor, kind, custom_kind,
371            payload_json, tags_json, headers_json, ts_ms, ts, record_hash, prev_hash,
372            signature_json
373        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
374        params![
375            event.session_id,
376            event.event_id as i64,
377            event.tenant_id,
378            event.parent_event_id.map(|value| value as i64),
379            event.actor,
380            kind,
381            custom_kind,
382            payload_json,
383            tags_json,
384            headers_json,
385            event.ts_ms,
386            event.ts,
387            event.record_hash,
388            event.prev_hash,
389            signature_json,
390        ],
391    )
392    .map_err(map_sql)?;
393    Ok(())
394}
395
396fn read_event(row: &rusqlite::Row) -> Result<StoredEvent, rusqlite::Error> {
397    let session_id: String = row.get(0)?;
398    let event_id: i64 = row.get(1)?;
399    let tenant_id: Option<String> = row.get(2)?;
400    let parent_event_id: Option<i64> = row.get(3)?;
401    let actor: Option<String> = row.get(4)?;
402    let kind: String = row.get(5)?;
403    let custom_kind: Option<String> = row.get(6)?;
404    let payload_json: String = row.get(7)?;
405    let tags_json: String = row.get(8)?;
406    let headers_json: String = row.get(9)?;
407    let ts_ms: i64 = row.get(10)?;
408    let ts: String = row.get(11)?;
409    let record_hash: String = row.get(12)?;
410    let prev_hash: Option<String> = row.get(13)?;
411    let signature_json: Option<String> = row.get(14)?;
412    let resolved_kind = kind_from_sql(&kind, custom_kind).map_err(|error| {
413        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, error.into())
414    })?;
415    let payload = serde_json::from_str(&payload_json).map_err(|error| {
416        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, error.into())
417    })?;
418    let tags = serde_json::from_str(&tags_json).unwrap_or_default();
419    let headers = serde_json::from_str(&headers_json).unwrap_or_default();
420    let signed_by = signature_json
421        .as_ref()
422        .map(|value| serde_json::from_str::<EventSignature>(value))
423        .transpose()
424        .map_err(|error| {
425            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, error.into())
426        })?;
427    Ok(StoredEvent {
428        event_id: event_id as EventId,
429        session_id,
430        tenant_id,
431        parent_event_id: parent_event_id.map(|value| value as EventId),
432        actor,
433        kind: resolved_kind,
434        payload,
435        tags,
436        headers,
437        ts_ms,
438        ts,
439        record_hash,
440        prev_hash,
441        signed_by,
442    })
443}
444
445fn load_all_events(conn: &Connection, session_id: &str) -> StoreResult<Vec<StoredEvent>> {
446    let mut stmt = conn
447        .prepare(
448            "SELECT session_id, event_id, tenant_id, parent_event_id, actor, kind,
449                    custom_kind, payload_json, tags_json, headers_json, ts_ms, ts,
450                    record_hash, prev_hash, signature_json
451             FROM session_events WHERE session_id = ?1 ORDER BY event_id ASC",
452        )
453        .map_err(map_sql)?;
454    let rows = stmt
455        .query_map(params![session_id], read_event)
456        .map_err(map_sql)?;
457    let mut out = Vec::new();
458    for row in rows {
459        out.push(row.map_err(map_sql)?);
460    }
461    Ok(out)
462}
463
464fn apply_redaction(hooks: &StoreHooks, event: &mut AppendEvent) {
465    let Some(policy) = hooks.redaction.as_ref() else {
466        return;
467    };
468    policy.redact_json_in_place(&mut event.payload);
469    event.headers = policy.redact_headers(&event.headers);
470}
471
472/// Core append logic, operating on a caller-owned connection (typically a
473/// transaction). Redacts, validates, links, signs (when an event signer
474/// is configured), inserts the event, and advances the session counters —
475/// but does **not** commit. `append` wraps this in its own transaction;
476/// `close` reuses it so the receipt insert, its signature, and the status
477/// flip all land in a single atomic transaction.
478fn append_in_tx(
479    conn: &Connection,
480    hooks: &StoreHooks,
481    session_id: &str,
482    mut event: AppendEvent,
483) -> StoreResult<StoredEvent> {
484    apply_redaction(hooks, &mut event);
485    let (mut meta, next_event_id) = read_session_meta(conn, session_id)?;
486    super::memory_helpers::validate_open(&meta)?;
487    if let Some(parent_event_id) = event.parent_event_id {
488        let exists: bool = conn
489            .query_row(
490                "SELECT 1 FROM session_events WHERE session_id = ?1 AND event_id = ?2",
491                params![session_id, parent_event_id as i64],
492                |_| Ok(true),
493            )
494            .optional()
495            .map_err(map_sql)?
496            .unwrap_or(false);
497        if !exists {
498            return Err(StoreError::InvalidInput(format!(
499                "parent_event_id {parent_event_id} not present in session"
500            )));
501        }
502    }
503    let prev_hash: Option<String> = conn
504        .query_row(
505            "SELECT record_hash FROM session_events
506             WHERE session_id = ?1 ORDER BY event_id DESC LIMIT 1",
507            params![session_id],
508            |row| row.get(0),
509        )
510        .optional()
511        .map_err(map_sql)?;
512    let (ts_ms, ts) = now_ms_and_rfc3339();
513    let mut stored = StoredEvent {
514        event_id: next_event_id,
515        session_id: session_id.to_string(),
516        tenant_id: meta.tenant_id.clone(),
517        parent_event_id: event.parent_event_id,
518        actor: event.actor,
519        kind: event.kind,
520        payload: event.payload,
521        tags: event.tags,
522        headers: event.headers,
523        ts_ms,
524        ts: ts.clone(),
525        record_hash: String::new(),
526        prev_hash,
527        signed_by: None,
528    };
529    stored.record_hash = compute_record_hash(&stored);
530    if let Some(signer) = hooks.event_signer.as_ref() {
531        stored.signed_by = Some(signer.sign_event(&stored));
532    }
533    insert_event(conn, &stored)?;
534    let prev_root = meta.chain_root_hash.clone().unwrap_or_else(chain_root_init);
535    let chain_root = chain_root_fold(&prev_root, &stored.record_hash);
536    meta.event_count = meta.event_count.saturating_add(1);
537    meta.last_event_id = Some(next_event_id);
538    meta.chain_root_hash = Some(chain_root);
539    meta.updated_at_ms = ts_ms;
540    meta.updated_at = ts;
541    conn.execute(
542        "UPDATE sessions SET event_count = ?1, last_event_id = ?2,
543                              chain_root_hash = ?3, updated_at_ms = ?4,
544                              updated_at = ?5, next_event_id = ?6 WHERE id = ?7",
545        params![
546            meta.event_count as i64,
547            meta.last_event_id.map(|value| value as i64),
548            meta.chain_root_hash,
549            meta.updated_at_ms,
550            meta.updated_at,
551            (next_event_id + 1) as i64,
552            session_id,
553        ],
554    )
555    .map_err(map_sql)?;
556    Ok(stored)
557}
558
559#[async_trait]
560impl SessionStore for SqliteSessionStore {
561    fn hooks(&self) -> &StoreHooks {
562        &self.hooks
563    }
564
565    async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta> {
566        let meta = super::memory_helpers::meta_for_create(request);
567        let conn = self.lock();
568        if conn
569            .query_row(
570                "SELECT 1 FROM sessions WHERE id = ?1",
571                params![meta.id],
572                |_| Ok(()),
573            )
574            .optional()
575            .map_err(map_sql)?
576            .is_some()
577        {
578            return Err(StoreError::AlreadyExists(meta.id));
579        }
580        insert_session(&conn, &meta, 1)?;
581        Ok(meta)
582    }
583
584    async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta> {
585        let conn = self.lock();
586        let (meta, _) = read_session_meta(&conn, session_id)?;
587        Ok(meta)
588    }
589
590    async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>> {
591        let conn = self.lock();
592        let limit = filter.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
593        // Pull the cursor's anchor row up front so the SQL can do
594        // keyset pagination on `(created_at_ms, id)` instead of scanning
595        // every prior row into memory.
596        let cursor_anchor: Option<(i64, String)> = filter
597            .cursor
598            .as_ref()
599            .map(|id| {
600                conn.query_row(
601                    "SELECT created_at_ms, id FROM sessions WHERE id = ?1",
602                    params![id],
603                    |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
604                )
605                .optional()
606                .map_err(map_sql)
607            })
608            .transpose()?
609            .flatten();
610
611        let mut sql = String::from("SELECT s.id FROM sessions s");
612        if filter.tag.is_some() {
613            sql.push_str(" INNER JOIN session_tags t ON t.session_id = s.id AND t.tag = :tag");
614        }
615        sql.push_str(" WHERE 1=1");
616        let mut args: Vec<(&'static str, rusqlite::types::Value)> = Vec::new();
617        if let Some(tag) = filter.tag {
618            args.push((":tag", tag.into()));
619        }
620        if let Some(tenant) = filter.tenant_id {
621            sql.push_str(" AND s.tenant_id = :tenant");
622            args.push((":tenant", tenant.into()));
623        }
624        if let Some(persona) = filter.persona {
625            sql.push_str(" AND s.persona = :persona");
626            args.push((":persona", persona.into()));
627        }
628        if let Some(status) = filter.status {
629            sql.push_str(" AND s.status = :status");
630            args.push((":status", status_to_sql(status).to_string().into()));
631        }
632        if let Some(after) = filter.created_after_ms {
633            sql.push_str(" AND s.created_at_ms >= :after");
634            args.push((":after", after.into()));
635        }
636        if let Some(before) = filter.created_before_ms {
637            sql.push_str(" AND s.created_at_ms <= :before");
638            args.push((":before", before.into()));
639        }
640        if let Some((anchor_ms, anchor_id)) = cursor_anchor {
641            sql.push_str(
642                " AND (s.created_at_ms > :cursor_ms OR (s.created_at_ms = :cursor_ms AND s.id > :cursor_id))",
643            );
644            args.push((":cursor_ms", anchor_ms.into()));
645            args.push((":cursor_id", anchor_id.into()));
646        }
647        sql.push_str(" ORDER BY s.created_at_ms ASC, s.id ASC LIMIT :limit");
648        args.push((":limit", limit.into()));
649
650        let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
651            .iter()
652            .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
653            .collect();
654        let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
655        let ids: Vec<String> = stmt
656            .query_map(named_args.as_slice(), |row| row.get(0))
657            .map_err(map_sql)?
658            .collect::<Result<_, _>>()
659            .map_err(map_sql)?;
660        let mut metas = Vec::with_capacity(ids.len());
661        for id in ids {
662            let (meta, _) = read_session_meta(&conn, &id)?;
663            metas.push(meta);
664        }
665        Ok(metas)
666    }
667
668    async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent> {
669        let mut conn = self.lock();
670        let tx = conn.transaction().map_err(map_sql)?;
671        let stored = append_in_tx(&tx, &self.hooks, session_id, event)?;
672        tx.commit().map_err(map_sql)?;
673        Ok(stored)
674    }
675
676    async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage> {
677        let conn = self.lock();
678        let from = range.from_event_id.unwrap_or(1) as i64;
679        // SQLite stores event_id as INTEGER (signed i64); use i64::MAX as
680        // the unbounded upper sentinel rather than casting EventId::MAX,
681        // which silently wraps to -1.
682        let to = range
683            .to_event_id
684            .map(|value| value as i64)
685            .unwrap_or(i64::MAX);
686        let limit = range.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
687        let mut stmt = conn
688            .prepare(
689                "SELECT session_id, event_id, tenant_id, parent_event_id, actor, kind,
690                        custom_kind, payload_json, tags_json, headers_json, ts_ms, ts,
691                        record_hash, prev_hash, signature_json
692                 FROM session_events
693                 WHERE session_id = ?1 AND event_id >= ?2 AND event_id <= ?3
694                 ORDER BY event_id ASC LIMIT ?4",
695            )
696            .map_err(map_sql)?;
697        let rows = stmt
698            .query_map(params![session_id, from, to, limit], read_event)
699            .map_err(map_sql)?;
700        let mut events = Vec::new();
701        for row in rows {
702            events.push(row.map_err(map_sql)?);
703        }
704        if let Some(policy) = self.hooks.redaction.as_ref() {
705            for event in events.iter_mut() {
706                policy.redact_json_in_place(&mut event.payload);
707            }
708        }
709        let next_cursor = if events.len() as i64 == limit {
710            events.last().map(|tail| tail.event_id + 1)
711        } else {
712            None
713        };
714        Ok(EventPage {
715            events,
716            next_cursor,
717        })
718    }
719
720    async fn fork(
721        &self,
722        session_id: &str,
723        at_event_id: EventId,
724        child_id: Option<SessionId>,
725    ) -> StoreResult<ForkResult> {
726        let mut conn = self.lock();
727        let tx = conn.transaction().map_err(map_sql)?;
728        let (parent_meta, _) = read_session_meta(&tx, session_id)?;
729        let parent_events = load_all_events(&tx, session_id)?;
730        if !parent_events
731            .iter()
732            .any(|event| event.event_id == at_event_id)
733        {
734            return Err(StoreError::InvalidInput(format!(
735                "event {at_event_id} not found in session '{session_id}'"
736            )));
737        }
738        let new_id = child_id.unwrap_or_else(|| Uuid::now_v7().to_string());
739        let exists: bool = tx
740            .query_row(
741                "SELECT 1 FROM sessions WHERE id = ?1",
742                params![new_id],
743                |_| Ok(true),
744            )
745            .optional()
746            .map_err(map_sql)?
747            .unwrap_or(false);
748        if exists {
749            return Err(StoreError::AlreadyExists(new_id));
750        }
751        let (ms, text) = now_ms_and_rfc3339();
752        let mut child_meta = parent_meta.clone();
753        child_meta.id = new_id.clone();
754        child_meta.parent_session_id = Some(parent_meta.id);
755        child_meta.created_at_ms = ms;
756        child_meta.created_at = text.clone();
757        child_meta.updated_at_ms = ms;
758        child_meta.updated_at = text;
759        child_meta.status = SessionStatus::Open;
760        child_meta.closed_at_ms = None;
761        child_meta.closed_at = None;
762        child_meta.soft_deleted_at_ms = None;
763        let inherited: Vec<StoredEvent> = parent_events
764            .into_iter()
765            .filter(|event| event.event_id <= at_event_id)
766            .collect();
767        let copied = re_anchor_events(&inherited, &new_id);
768        child_meta.event_count = copied.len();
769        child_meta.last_event_id = copied.last().map(|tail| tail.event_id);
770        child_meta.chain_root_hash = Some(chain_root_hash(&copied));
771        let next_event_id = copied.last().map(|tail| tail.event_id + 1).unwrap_or(1);
772        insert_session(&tx, &child_meta, next_event_id)?;
773        for event in &copied {
774            insert_event(&tx, event)?;
775        }
776        tx.commit().map_err(map_sql)?;
777        Ok(ForkResult {
778            child_session_id: new_id,
779            forked_from_event_id: at_event_id,
780            copied_event_count: copied.len(),
781        })
782    }
783
784    async fn truncate(
785        &self,
786        session_id: &str,
787        at_event_id: EventId,
788    ) -> StoreResult<TruncateResult> {
789        let mut conn = self.lock();
790        let tx = conn.transaction().map_err(map_sql)?;
791        let (mut meta, _) = read_session_meta(&tx, session_id)?;
792        let exists: bool = tx
793            .query_row(
794                "SELECT 1 FROM session_events WHERE session_id = ?1 AND event_id = ?2",
795                params![session_id, at_event_id as i64],
796                |_| Ok(true),
797            )
798            .optional()
799            .map_err(map_sql)?
800            .unwrap_or(false);
801        if !exists {
802            return Err(StoreError::InvalidInput(format!(
803                "event {at_event_id} not found in session '{session_id}'"
804            )));
805        }
806        let removed: i64 = tx
807            .query_row(
808                "SELECT COUNT(*) FROM session_events
809                 WHERE session_id = ?1 AND event_id > ?2",
810                params![session_id, at_event_id as i64],
811                |row| row.get(0),
812            )
813            .map_err(map_sql)?;
814        tx.execute(
815            "DELETE FROM session_events WHERE session_id = ?1 AND event_id > ?2",
816            params![session_id, at_event_id as i64],
817        )
818        .map_err(map_sql)?;
819        let remaining_hashes: Vec<String> = {
820            let mut stmt = tx
821                .prepare(
822                    "SELECT record_hash FROM session_events
823                     WHERE session_id = ?1 ORDER BY event_id ASC",
824                )
825                .map_err(map_sql)?;
826            let rows = stmt
827                .query_map(params![session_id], |row| row.get::<_, String>(0))
828                .map_err(map_sql)?;
829            let mut out = Vec::new();
830            for row in rows {
831                out.push(row.map_err(map_sql)?);
832            }
833            out
834        };
835        let new_root = remaining_hashes
836            .iter()
837            .fold(chain_root_init(), |root, hash| chain_root_fold(&root, hash));
838        let (ms, text) = now_ms_and_rfc3339();
839        meta.event_count = remaining_hashes.len();
840        meta.last_event_id = Some(at_event_id);
841        meta.chain_root_hash = Some(new_root);
842        meta.updated_at_ms = ms;
843        meta.updated_at = text;
844        tx.execute(
845            "UPDATE sessions SET event_count = ?1, last_event_id = ?2,
846                                  chain_root_hash = ?3, updated_at_ms = ?4,
847                                  updated_at = ?5, next_event_id = ?6 WHERE id = ?7",
848            params![
849                meta.event_count as i64,
850                meta.last_event_id.map(|value| value as i64),
851                meta.chain_root_hash,
852                meta.updated_at_ms,
853                meta.updated_at,
854                (at_event_id + 1) as i64,
855                session_id,
856            ],
857        )
858        .map_err(map_sql)?;
859        tx.commit().map_err(map_sql)?;
860        Ok(TruncateResult {
861            kept_event_count: meta.event_count,
862            removed_event_count: removed as usize,
863            new_tip_event_id: meta.last_event_id,
864        })
865    }
866
867    async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot> {
868        let conn = self.lock();
869        let (meta, _) = read_session_meta(&conn, session_id)?;
870        let events = load_all_events(&conn, session_id)?;
871        let (ms, text) = now_ms_and_rfc3339();
872        let snapshot = Snapshot {
873            id: SnapshotId(format!("snap-{}", Uuid::now_v7())),
874            session: meta,
875            events,
876            captured_at_ms: ms,
877            captured_at: text,
878        };
879        let body = serde_json::to_string(&snapshot)
880            .map_err(|error| StoreError::Backend(error.to_string()))?;
881        conn.execute(
882            "INSERT INTO session_snapshots (id, session_id, captured_at_ms, captured_at, body_json)
883             VALUES (?1, ?2, ?3, ?4, ?5)",
884            params![
885                snapshot.id.0,
886                snapshot.session.id,
887                snapshot.captured_at_ms,
888                snapshot.captured_at,
889                body,
890            ],
891        )
892        .map_err(map_sql)?;
893        Ok(snapshot)
894    }
895
896    async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot> {
897        let conn = self.lock();
898        let body: Option<String> = conn
899            .query_row(
900                "SELECT body_json FROM session_snapshots WHERE id = ?1",
901                params![snapshot_id.0],
902                |row| row.get(0),
903            )
904            .optional()
905            .map_err(map_sql)?;
906        let body = body.ok_or_else(|| StoreError::NotFound(snapshot_id.0.clone()))?;
907        serde_json::from_str(&body).map_err(|error| StoreError::Backend(error.to_string()))
908    }
909
910    async fn close(&self, session_id: &str) -> StoreResult<StoredEvent> {
911        let mut conn = self.lock();
912        let tx = conn.transaction().map_err(map_sql)?;
913        // Read the pre-receipt chain root inside the transaction so the
914        // root we sign is exactly the chain the receipt finalises, with
915        // no window for a concurrent append to move the tip.
916        let (meta, _) = read_session_meta(&tx, session_id)?;
917        super::memory_helpers::validate_open(&meta)?;
918        let record_root = match meta.chain_root_hash.clone() {
919            Some(root) => root,
920            None => chain_root_hash(&load_all_events(&tx, session_id)?),
921        };
922        let last_event_id = meta.last_event_id.unwrap_or(0);
923        let payload =
924            super::signing::canonical_receipt_payload(session_id, last_event_id, &record_root);
925        let mut append = AppendEvent::new(SessionEventKind::Receipt, payload);
926        append.actor = Some("session_store".into());
927        let mut stored = append_in_tx(&tx, &self.hooks, session_id, append)?;
928        // Intentionally replace the receipt's append-time per-event
929        // signature with a receipt-root signature. The receipt's purpose
930        // is to attest the chain root, so `verify()` special-cases it via
931        // `verify_receipt_root` against the pre-receipt root rather than
932        // the receipt event's own canonical bytes.
933        if let Some(signer) = self
934            .hooks
935            .receipt_signer
936            .as_ref()
937            .or(self.hooks.event_signer.as_ref())
938        {
939            let signature = signer.sign_receipt(&record_root);
940            let signature_json =
941                serde_json::to_string(&signature).unwrap_or_else(|_| "null".into());
942            tx.execute(
943                "UPDATE session_events SET signature_json = ?1
944                 WHERE session_id = ?2 AND event_id = ?3",
945                params![signature_json, session_id, stored.event_id as i64],
946            )
947            .map_err(map_sql)?;
948            stored.signed_by = Some(signature);
949        }
950        let (ms, text) = now_ms_and_rfc3339();
951        tx.execute(
952            "UPDATE sessions SET status = ?1, closed_at_ms = ?2, closed_at = ?3,
953                                  updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
954            params![status_to_sql(SessionStatus::Closed), ms, text, session_id,],
955        )
956        .map_err(map_sql)?;
957        tx.commit().map_err(map_sql)?;
958        Ok(stored)
959    }
960
961    async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta> {
962        let conn = self.lock();
963        let (mut meta, _) = read_session_meta(&conn, session_id)?;
964        match meta.status {
965            SessionStatus::HardDeleted => return Err(StoreError::NotFound(session_id.to_string())),
966            SessionStatus::SoftDeleted => return Ok(meta),
967            _ => {}
968        }
969        let (ms, text) = now_ms_and_rfc3339();
970        conn.execute(
971            "UPDATE sessions SET status = ?1, soft_deleted_at_ms = ?2,
972                                  updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
973            params![
974                status_to_sql(SessionStatus::SoftDeleted),
975                ms,
976                text,
977                session_id,
978            ],
979        )
980        .map_err(map_sql)?;
981        meta.status = SessionStatus::SoftDeleted;
982        meta.soft_deleted_at_ms = Some(ms);
983        meta.updated_at_ms = ms;
984        meta.updated_at = text;
985        Ok(meta)
986    }
987
988    async fn hard_delete(&self, session_id: &str) -> StoreResult<()> {
989        let conn = self.lock();
990        let removed = conn
991            .execute("DELETE FROM sessions WHERE id = ?1", params![session_id])
992            .map_err(map_sql)?;
993        if removed == 0 {
994            return Err(StoreError::NotFound(session_id.to_string()));
995        }
996        Ok(())
997    }
998
999    async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport> {
1000        let conn = self.lock();
1001        let events = load_all_events(&conn, session_id)?;
1002        let chain_root = chain_root_hash(&events);
1003        let event_verifier = self
1004            .hooks
1005            .event_signer
1006            .as_ref()
1007            .map(|signer| signer.verifying_key());
1008        let receipt_verifier = self
1009            .hooks
1010            .receipt_signer
1011            .as_ref()
1012            .or(self.hooks.event_signer.as_ref())
1013            .map(|signer| signer.verifying_key());
1014        let (signed, failures) =
1015            verify_event_chain(&events, event_verifier.as_ref(), receipt_verifier.as_ref());
1016        Ok(VerifyReport {
1017            session_id: session_id.to_string(),
1018            chain_root_hash: chain_root,
1019            event_count: events.len(),
1020            signed_event_count: signed,
1021            failures: failures
1022                .into_iter()
1023                .map(|(event_id, reason)| VerifyFailure { event_id, reason })
1024                .collect(),
1025        })
1026    }
1027}