Skip to main content

khive_db/stores/
event.rs

1//! SQL-backed `EventStore` implementation.
2//!
3//! FILE SIZE JUSTIFICATION: Event store covers append, query-by-filter,
4//! observation recording, and paginated listing with shared row-mapping and
5//! timestamp serialization helpers. The event schema has complex JSON data
6//! columns (observations, referent kinds, outcomes) whose parsing is shared
7//! across all read paths, making a split impractical without duplicating the
8//! deserialization logic.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use uuid::Uuid;
14
15use khive_storage::error::StorageError;
16use khive_storage::event::{Event, EventFilter, EventObservation, ObservationRole, ReferentKind};
17use khive_storage::types::{BatchWriteSummary, Page, PageRequest, SqlStatement, SqlValue};
18use khive_storage::EventStore;
19use khive_storage::SqlWriter;
20use khive_storage::StorageCapability;
21use khive_types::{EventKind, EventOutcome, SubstrateKind};
22
23use crate::error::SqliteError;
24use crate::pool::ConnectionPool;
25use crate::writer_task::WriterTaskHandle;
26
27fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
28    StorageError::driver(StorageCapability::Events, op, e)
29}
30
31fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
32    StorageError::driver(StorageCapability::Events, op, e)
33}
34
35/// An EventStore backed by SQLite tables.
36pub struct SqlEventStore {
37    pool: Arc<ConnectionPool>,
38    is_file_backed: bool,
39    namespace: String,
40    writer_task: Option<WriterTaskHandle>,
41}
42
43impl SqlEventStore {
44    /// Create a new store scoped to one namespace.
45    pub fn new_scoped(
46        pool: Arc<ConnectionPool>,
47        is_file_backed: bool,
48        namespace: impl Into<String>,
49    ) -> Self {
50        // Best-effort opt-in (ADR-067 Component A, mirrors entity.rs slice 1
51        // policy): a missing writer task degrades to the legacy pool-mutex /
52        // standalone-connection path rather than failing construction.
53        let writer_task = pool.writer_task_handle().ok().flatten();
54        Self {
55            pool,
56            is_file_backed,
57            namespace: namespace.into(),
58            writer_task,
59        }
60    }
61
62    fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
63        self.pool
64            .open_standalone_writer()
65            .map_err(|e| map_sqlite_err(e, "open_event_writer"))
66    }
67
68    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
69        self.pool
70            .open_standalone_reader()
71            .map_err(|e| map_sqlite_err(e, "open_event_reader"))
72    }
73
74    /// Route a single-row write through the pool-wide `WriterTask` when
75    /// `KHIVE_WRITE_QUEUE=1` and a handle is available; otherwise fall back
76    /// to the legacy standalone-connection / pool-mutex path (ADR-067
77    /// Component A, Fork C slice 2).
78    ///
79    /// `append_event`/`append_events` do their own flag check and return
80    /// early on `Some`, so their fallback calls into this helper only ever
81    /// execute on the flag-off path (`self.writer_task` is `None` by
82    /// construction whenever those calls are reached) — no double-routing.
83    /// `f` must be DML-only on the flag-on path (no bare `BEGIN IMMEDIATE`)
84    /// since it runs inside the WriterTask's own transaction.
85    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
86    where
87        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
88        R: Send + 'static,
89    {
90        if let Some(writer_task) = &self.writer_task {
91            return writer_task
92                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
93                .await;
94        }
95
96        if self.is_file_backed {
97            let conn = self.open_standalone_writer()?;
98            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
99                .await
100                .map_err(|e| StorageError::driver(StorageCapability::Events, op, e))?
101        } else {
102            let pool = Arc::clone(&self.pool);
103            tokio::task::spawn_blocking(move || {
104                let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
105                f(guard.conn()).map_err(|e| map_err(e, op))
106            })
107            .await
108            .map_err(|e| StorageError::driver(StorageCapability::Events, op, e))?
109        }
110    }
111
112    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
113    where
114        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
115        R: Send + 'static,
116    {
117        if self.is_file_backed {
118            let conn = self.open_standalone_reader()?;
119            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
120                .await
121                .map_err(|e| StorageError::driver(StorageCapability::Events, op, e))?
122        } else {
123            let pool = Arc::clone(&self.pool);
124            tokio::task::spawn_blocking(move || {
125                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
126                f(guard.conn()).map_err(|e| map_err(e, op))
127            })
128            .await
129            .map_err(|e| StorageError::driver(StorageCapability::Events, op, e))?
130        }
131    }
132}
133
134// =============================================================================
135// Helpers: parse SubstrateKind / EventOutcome / EventKind from DB strings
136// =============================================================================
137
138fn substrate_from_str(s: &str) -> Result<SubstrateKind, rusqlite::Error> {
139    s.parse::<SubstrateKind>().map_err(|_| {
140        rusqlite::Error::FromSqlConversionFailure(
141            0,
142            rusqlite::types::Type::Text,
143            format!("unknown SubstrateKind: {s}").into(),
144        )
145    })
146}
147
148fn outcome_from_str(s: &str) -> Result<EventOutcome, rusqlite::Error> {
149    match s {
150        "success" => Ok(EventOutcome::Success),
151        "denied" => Ok(EventOutcome::Denied),
152        "error" => Ok(EventOutcome::Error),
153        other => Err(rusqlite::Error::FromSqlConversionFailure(
154            0,
155            rusqlite::types::Type::Text,
156            format!("unknown EventOutcome: {other}").into(),
157        )),
158    }
159}
160
161fn kind_from_str(s: &str) -> Result<EventKind, rusqlite::Error> {
162    s.parse::<EventKind>().map_err(|_| {
163        rusqlite::Error::FromSqlConversionFailure(
164            0,
165            rusqlite::types::Type::Text,
166            format!("unknown EventKind: {s}").into(),
167        )
168    })
169}
170
171fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
172    Uuid::parse_str(s).map_err(|e| {
173        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
174    })
175}
176
177// Column order: id(0), namespace(1), verb(2), substrate(3), actor(4),
178//               kind(5), outcome(6), payload(7), payload_schema_version(8),
179//               profile_state_version(9), duration_us(10), target_id(11),
180//               session_id(12), aggregate_kind(13), aggregate_id(14), created_at(15)
181fn read_event(row: &rusqlite::Row<'_>) -> Result<Event, rusqlite::Error> {
182    let id_str: String = row.get(0)?;
183    let namespace: String = row.get(1)?;
184    let verb: String = row.get(2)?;
185    let substrate_str: String = row.get(3)?;
186    let actor: String = row.get(4)?;
187    let kind_str: String = row.get(5)?;
188    let outcome_str: String = row.get(6)?;
189    let payload_str: String = row.get(7)?;
190    let payload_schema_version: i64 = row.get(8)?;
191    let profile_state_version: Option<i64> = row.get(9)?;
192    let duration_us: i64 = row.get(10)?;
193    let target_str: Option<String> = row.get(11)?;
194    let session_str: Option<String> = row.get(12)?;
195    let aggregate_kind: Option<String> = row.get(13)?;
196    let aggregate_str: Option<String> = row.get(14)?;
197    let created_at: i64 = row.get(15)?;
198
199    let id = parse_uuid(&id_str)?;
200    let substrate = substrate_from_str(&substrate_str)?;
201    let kind = kind_from_str(&kind_str)?;
202    let outcome = outcome_from_str(&outcome_str)?;
203    let payload: serde_json::Value = serde_json::from_str(&payload_str).map_err(|e| {
204        rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
205    })?;
206    let target_id = target_str.as_deref().map(parse_uuid).transpose()?;
207    let session_id = session_str.as_deref().map(parse_uuid).transpose()?;
208    let aggregate_id = aggregate_str.as_deref().map(parse_uuid).transpose()?;
209    let payload_schema_version_u32: u32 = payload_schema_version.try_into().map_err(|_| {
210        rusqlite::Error::FromSqlConversionFailure(
211            8,
212            rusqlite::types::Type::Integer,
213            format!("payload_schema_version {payload_schema_version} out of u32 range").into(),
214        )
215    })?;
216    let profile_state_version_u64: Option<u64> = profile_state_version
217        .map(|v| {
218            u64::try_from(v).map_err(|_| {
219                rusqlite::Error::FromSqlConversionFailure(
220                    9,
221                    rusqlite::types::Type::Integer,
222                    format!("profile_state_version {v} out of u64 range").into(),
223                )
224            })
225        })
226        .transpose()?;
227
228    Ok(Event {
229        id,
230        namespace,
231        verb,
232        substrate,
233        actor,
234        kind,
235        outcome,
236        payload,
237        payload_schema_version: payload_schema_version_u32,
238        profile_state_version: profile_state_version_u64,
239        duration_us,
240        target_id,
241        session_id,
242        aggregate_kind,
243        aggregate_id,
244        created_at,
245    })
246}
247
248// =============================================================================
249// Helpers: observation projection write path
250// =============================================================================
251
252fn insert_event_with_observations(
253    conn: &rusqlite::Connection,
254    event: &Event,
255) -> Result<(), rusqlite::Error> {
256    let id_str = event.id.to_string();
257    let substrate_str = event.substrate.name().to_string();
258    let kind_str = event.kind.name().to_string();
259    let outcome_str = event.outcome.name().to_string();
260    let payload_str = event.payload.to_string();
261    let target_str = event.target_id.map(|u| u.to_string());
262    let session_str = event.session_id.map(|u| u.to_string());
263    let aggregate_str = event.aggregate_id.map(|u| u.to_string());
264    let profile_state_version = event.profile_state_version.map(|v| v as i64);
265
266    conn.execute(
267        "INSERT INTO events \
268         (id, namespace, verb, substrate, actor, kind, outcome, payload, payload_schema_version, \
269          profile_state_version, duration_us, target_id, session_id, aggregate_kind, aggregate_id, created_at) \
270         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
271        rusqlite::params![
272            id_str,
273            &event.namespace,
274            &event.verb,
275            substrate_str,
276            &event.actor,
277            kind_str,
278            outcome_str,
279            payload_str,
280            event.payload_schema_version as i64,
281            profile_state_version,
282            event.duration_us,
283            target_str,
284            session_str,
285            &event.aggregate_kind,
286            aggregate_str,
287            event.created_at,
288        ],
289    )?;
290
291    for observation in decode_event_observations(event)? {
292        conn.execute(
293            "INSERT INTO event_observations \
294             (event_id, entity_id, referent_kind, role, position) \
295             VALUES (?1, ?2, ?3, ?4, ?5)",
296            rusqlite::params![
297                observation.event_id.to_string(),
298                observation.entity_id.to_string(),
299                observation.referent_kind.name(),
300                observation.role.name(),
301                observation.position as i64,
302            ],
303        )?;
304    }
305
306    Ok(())
307}
308
309/// DML-only batch append loop shared by both the legacy (flag-off) and
310/// WriterTask-routed (flag-on) `append_events` paths (ADR-067 Component A).
311///
312/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
313/// enclosing transaction. All-or-nothing: the first failed insert returns
314/// `Err` immediately (matching the pre-existing `append_events` contract) —
315/// the caller's transaction wrapper issues the ROLLBACK.
316fn batch_append_events_dml(
317    conn: &rusqlite::Connection,
318    events: &[Event],
319    attempted: u64,
320) -> Result<BatchWriteSummary, rusqlite::Error> {
321    let mut affected = 0u64;
322    for event in events {
323        insert_event_with_observations(conn, event)?;
324        affected += 1;
325    }
326    Ok(BatchWriteSummary {
327        attempted,
328        affected,
329        failed: 0,
330        first_error: String::new(),
331    })
332}
333
334/// Pure statement builder (ADR-099 B3 r6 structural cut): the exact
335/// `events` row insert plus every derived `event_observations` insert for
336/// `event`, as plain [`SqlStatement`]s with no I/O. This is the ONE place
337/// that knows the `events`/`event_observations` insert shape at the
338/// statement-text level; both [`append_event_on_writer`] below (the async
339/// `SqlWriter` execution path) and `khive-runtime`'s ADR-099 `--atomic`
340/// prepare path (which needs plain-data statements it can fold into a
341/// synchronous commit-phase plan, not an async writer call) build on this
342/// function rather than each hand-writing the INSERT text — the divergence
343/// that produced the ADR-099 B3 round-4 finding this cut fixes.
344pub fn event_insert_statements(event: &Event) -> Result<Vec<SqlStatement>, rusqlite::Error> {
345    let id_str = event.id.to_string();
346    let substrate_str = event.substrate.name().to_string();
347    let kind_str = event.kind.name().to_string();
348    let outcome_str = event.outcome.name().to_string();
349    let payload_str = event.payload.to_string();
350    let target_str = event.target_id.map(|u| u.to_string());
351    let session_str = event.session_id.map(|u| u.to_string());
352    let aggregate_str = event.aggregate_id.map(|u| u.to_string());
353    let profile_state_version = event.profile_state_version.map(|v| v as i64);
354
355    let mut statements = vec![SqlStatement {
356        sql: "INSERT INTO events \
357              (id, namespace, verb, substrate, actor, kind, outcome, payload, payload_schema_version, \
358               profile_state_version, duration_us, target_id, session_id, aggregate_kind, aggregate_id, created_at) \
359              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)"
360            .into(),
361        params: vec![
362            SqlValue::Text(id_str),
363            SqlValue::Text(event.namespace.clone()),
364            SqlValue::Text(event.verb.clone()),
365            SqlValue::Text(substrate_str),
366            SqlValue::Text(event.actor.clone()),
367            SqlValue::Text(kind_str),
368            SqlValue::Text(outcome_str),
369            SqlValue::Text(payload_str),
370            SqlValue::Integer(event.payload_schema_version as i64),
371            profile_state_version
372                .map(SqlValue::Integer)
373                .unwrap_or(SqlValue::Null),
374            SqlValue::Integer(event.duration_us),
375            target_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
376            session_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
377            event
378                .aggregate_kind
379                .clone()
380                .map(SqlValue::Text)
381                .unwrap_or(SqlValue::Null),
382            aggregate_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
383            SqlValue::Integer(event.created_at),
384        ],
385        label: Some("event_insert_on_writer".into()),
386    }];
387
388    for observation in decode_event_observations(event)? {
389        statements.push(SqlStatement {
390            sql: "INSERT INTO event_observations \
391                  (event_id, entity_id, referent_kind, role, position) \
392                  VALUES (?1, ?2, ?3, ?4, ?5)"
393                .into(),
394            params: vec![
395                SqlValue::Text(observation.event_id.to_string()),
396                SqlValue::Text(observation.entity_id.to_string()),
397                SqlValue::Text(observation.referent_kind.name().to_string()),
398                SqlValue::Text(observation.role.name().to_string()),
399                SqlValue::Integer(observation.position as i64),
400            ],
401            label: Some("event_observation_insert_on_writer".into()),
402        });
403    }
404
405    Ok(statements)
406}
407
408/// Insert `event` (and any derived `event_observations` rows) through the
409/// `khive-storage` `SqlWriter` seam, on a transaction the CALLER already
410/// opened and controls the commit/rollback boundary for. Issues no
411/// `BEGIN`/`COMMIT`/`ROLLBACK` of its own.
412///
413/// This exists alongside `insert_event_with_observations` (the raw
414/// `rusqlite::Connection` path `SqlEventStore::append_event` uses, which
415/// opens its own `BEGIN IMMEDIATE`/`COMMIT` for the ordinary
416/// caller-owns-nothing case) rather than replacing it: the two run on
417/// different connection abstractions — a standalone `rusqlite::Connection`
418/// vs. a `Box<dyn SqlWriter>` — so they cannot share one function body. Both
419/// build on [`event_insert_statements`] for the actual insert shape.
420/// Callers that need the event append to be part of a larger atomic unit —
421/// e.g. ADR-081's brain fold gate (`khive-pack-brain/src/fold_gate.rs`),
422/// which holds its own `BEGIN IMMEDIATE` transaction on a `SqlWriter` for
423/// the dedup claim + mass fold and needs the feedback event to land in that
424/// same transaction — call this instead of duplicating the insert shape
425/// into their own crate.
426pub async fn append_event_on_writer(
427    writer: &mut dyn SqlWriter,
428    event: &Event,
429) -> Result<(), StorageError> {
430    let statements =
431        event_insert_statements(event).map_err(|e| map_err(e, "decode_event_observations"))?;
432    for statement in statements {
433        writer.execute(statement).await?;
434    }
435    Ok(())
436}
437
438fn decode_event_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
439    match event.kind {
440        EventKind::RerankExecuted => decode_rerank_observations(event),
441        EventKind::RecallExecuted | EventKind::SearchExecuted => decode_recall_observations(event),
442        EventKind::LinkCreated => decode_link_observations(event),
443        EventKind::EntityCreated
444        | EventKind::EntityUpdated
445        | EventKind::EntityDeleted
446        | EventKind::NoteCreated
447        | EventKind::NoteUpdated
448        | EventKind::NoteDeleted
449        | EventKind::TaskTransitioned => decode_target_observation(event),
450        EventKind::FeedbackExplicit => decode_signal_observation(event),
451        _ => Ok(Vec::new()),
452    }
453}
454
455fn payload_uuid_array(event: &Event, field: &'static str) -> Result<Vec<Uuid>, rusqlite::Error> {
456    Ok(payload_uuid_array_opt(event, field)?.unwrap_or_default())
457}
458
459/// Like [`payload_uuid_array`], but distinguishes an absent `field` (`Ok(None)`)
460/// from a present-but-malformed one (`Err`). Callers that fall back across a
461/// chain of alternative field names (e.g. `decode_rerank_observations`'s
462/// `final_scores`/`reranked`) need that distinction: collapsing
463/// "missing" into `Ok(vec![])` makes every later field in the chain
464/// unreachable, since `Result::or_else` only fires on `Err`.
465fn payload_uuid_array_opt(
466    event: &Event,
467    field: &'static str,
468) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
469    let Some(values) = event.payload.get(field) else {
470        return Ok(None);
471    };
472    let Some(array) = values.as_array() else {
473        return Err(invalid_payload(event.kind, field, "expected array"));
474    };
475
476    array
477        .iter()
478        .map(|value| {
479            value
480                .as_str()
481                .ok_or_else(|| invalid_payload(event.kind, field, "expected UUID string"))
482                .and_then(|s| Uuid::parse_str(s).map_err(|e| invalid_payload(event.kind, field, e)))
483        })
484        .collect::<Result<Vec<_>, _>>()
485        .map(Some)
486}
487
488/// Decode `RerankExecutedPayload::final_scores` (`Vec<(Id128, f32)>` per
489/// `khive_types::event::RerankExecutedPayload`) into the UUID leading each
490/// tuple. Deserializes through that exact typed tuple shape, so a tuple that
491/// is not precisely two elements, or whose second element is not a finite
492/// score, is rejected rather than silently accepted. Absent field is
493/// `Ok(None)`; present-but-wrong-shape is `Err`, matching
494/// [`payload_uuid_array_opt`]'s missing-vs-malformed contract.
495fn payload_final_scores_uuid_array_opt(
496    event: &Event,
497    field: &'static str,
498) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
499    let Some(values) = event.payload.get(field) else {
500        return Ok(None);
501    };
502    let tuples: Vec<(khive_types::Id128, f32)> = serde_json::from_value(values.clone())
503        .map_err(|e| invalid_payload(event.kind, field, e))?;
504    tuples
505        .into_iter()
506        .map(|(id, score)| {
507            if !score.is_finite() {
508                return Err(invalid_payload(event.kind, field, "score is not finite"));
509            }
510            Ok(Uuid::from_bytes(*id.as_bytes()))
511        })
512        .collect::<Result<Vec<_>, _>>()
513        .map(Some)
514}
515
516/// Decode `RerankExecutedPayload::reranked` (`Vec<(Id128, Vec<(String, f32)>)>`
517/// per `khive_types::event::RerankExecutedPayload`) into the UUID leading each
518/// tuple. Same exact-shape decoding contract as
519/// [`payload_final_scores_uuid_array_opt`], applied to `reranked`'s
520/// per-reranker sub-score shape instead of a single scalar score.
521fn payload_reranked_uuid_array_opt(
522    event: &Event,
523    field: &'static str,
524) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
525    let Some(values) = event.payload.get(field) else {
526        return Ok(None);
527    };
528    let tuples: Vec<(khive_types::Id128, Vec<(String, f32)>)> =
529        serde_json::from_value(values.clone())
530            .map_err(|e| invalid_payload(event.kind, field, e))?;
531    tuples
532        .into_iter()
533        .map(|(id, scores)| {
534            if !scores.iter().all(|(_, s)| s.is_finite()) {
535                return Err(invalid_payload(event.kind, field, "score is not finite"));
536            }
537            Ok(Uuid::from_bytes(*id.as_bytes()))
538        })
539        .collect::<Result<Vec<_>, _>>()
540        .map(Some)
541}
542
543fn payload_uuid(event: &Event, field: &'static str) -> Result<Option<Uuid>, rusqlite::Error> {
544    let Some(value) = event.payload.get(field) else {
545        return Ok(None);
546    };
547    let Some(s) = value.as_str() else {
548        return Err(invalid_payload(event.kind, field, "expected UUID string"));
549    };
550    Uuid::parse_str(s)
551        .map(Some)
552        .map_err(|e| invalid_payload(event.kind, field, e))
553}
554
555fn decode_candidate_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
556    let mut rows = Vec::new();
557
558    for (position, entity_id) in payload_uuid_array(event, "candidates")?
559        .into_iter()
560        .enumerate()
561    {
562        let position_u32 = u32::try_from(position).map_err(|_| {
563            invalid_payload(
564                event.kind,
565                "candidates[position]",
566                "position out of u32 range",
567            )
568        })?;
569        rows.push(EventObservation {
570            event_id: event.id,
571            entity_id,
572            referent_kind: ReferentKind::Note,
573            role: ObservationRole::Candidate,
574            position: position_u32,
575        });
576    }
577
578    Ok(rows)
579}
580
581fn push_selected_observations(
582    event: &Event,
583    selected: Vec<Uuid>,
584    rows: &mut Vec<EventObservation>,
585) -> Result<(), rusqlite::Error> {
586    for (position, entity_id) in selected.into_iter().enumerate() {
587        let position_u32 = u32::try_from(position).map_err(|_| {
588            invalid_payload(
589                event.kind,
590                "selected[position]",
591                "position out of u32 range",
592            )
593        })?;
594        rows.push(EventObservation {
595            event_id: event.id,
596            entity_id,
597            referent_kind: ReferentKind::Note,
598            role: ObservationRole::Selected,
599            position: position_u32,
600        });
601    }
602    Ok(())
603}
604
605/// `RecallExecuted`/`SearchExecuted` payloads carry a flat `selected: Vec<Uuid>`
606/// field (ADR-041 §"Projection rules"). These payloads are untyped JSON; the
607/// ADR-041 projection contract makes `selected` the only field consulted here.
608fn decode_recall_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
609    let mut rows = decode_candidate_observations(event)?;
610    let selected = payload_uuid_array_opt(event, "selected")?.unwrap_or_default();
611    push_selected_observations(event, selected, &mut rows)?;
612    Ok(rows)
613}
614
615/// `RerankExecutedPayload` (`khive_types::event::RerankExecutedPayload`) has no
616/// `selected` field at all — a stray `selected` key in the raw payload is not
617/// part of its typed contract and must never be consulted. Per ADR-042 §5,
618/// `final_scores` is the ordered rerank output (positions match output
619/// order); `reranked` is per-reranker audit/debug data with no ordering
620/// guarantee, so it is only a legacy fallback for events emitted before
621/// `final_scores` existed. `reranked` is tried only when `final_scores` is
622/// absent (`None`) — a present-but-malformed `final_scores` errors
623/// immediately instead of masking the problem by falling through.
624fn decode_rerank_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
625    let mut rows = decode_candidate_observations(event)?;
626    let selected = payload_final_scores_uuid_array_opt(event, "final_scores")?
627        .or(payload_reranked_uuid_array_opt(event, "reranked")?)
628        .unwrap_or_default();
629    push_selected_observations(event, selected, &mut rows)?;
630
631    Ok(rows)
632}
633
634fn decode_link_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
635    let mut rows = Vec::new();
636    if let Some(source) = payload_uuid(event, "source_id")? {
637        rows.push(EventObservation {
638            event_id: event.id,
639            entity_id: source,
640            referent_kind: ReferentKind::Entity,
641            role: ObservationRole::Target,
642            position: 0,
643        });
644    }
645    if let Some(target) = payload_uuid(event, "target_id")? {
646        rows.push(EventObservation {
647            event_id: event.id,
648            entity_id: target,
649            referent_kind: ReferentKind::Entity,
650            role: ObservationRole::Target,
651            position: 1,
652        });
653    }
654    Ok(rows)
655}
656
657fn decode_target_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
658    let Some(entity_id) = event.target_id.or(payload_uuid(event, "target_id")?) else {
659        return Ok(Vec::new());
660    };
661    Ok(vec![EventObservation {
662        event_id: event.id,
663        entity_id,
664        referent_kind: if event.substrate == SubstrateKind::Note {
665            ReferentKind::Note
666        } else {
667            ReferentKind::Entity
668        },
669        role: ObservationRole::Target,
670        position: 0,
671    }])
672}
673
674fn decode_signal_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
675    let Some(entity_id) = event.target_id else {
676        return Ok(Vec::new());
677    };
678    Ok(vec![EventObservation {
679        event_id: event.id,
680        entity_id,
681        // ADR-041 permits both entity and note signal targets. `brain.feedback`
682        // threads the resolved target's substrate onto the event (Finding 1,
683        // round-1 codex review of #831); pre-fix events all carry the old
684        // `SubstrateKind::Event` placeholder and fall back to Entity, matching
685        // the decoder's prior hard-coded behavior for that historical data.
686        referent_kind: if event.substrate == SubstrateKind::Note {
687            ReferentKind::Note
688        } else {
689            ReferentKind::Entity
690        },
691        role: ObservationRole::Signal,
692        position: 0,
693    }])
694}
695
696fn invalid_payload(
697    kind: EventKind,
698    field: &'static str,
699    reason: impl std::fmt::Display,
700) -> rusqlite::Error {
701    rusqlite::Error::ToSqlConversionFailure(
702        format!("invalid payload for {}.{field}: {reason}", kind.name()).into(),
703    )
704}
705
706// =============================================================================
707// Helpers: filter SQL builder
708// =============================================================================
709
710fn build_event_filter_sql(
711    conn: &rusqlite::Connection,
712    default_namespace: &str,
713    filter: &EventFilter,
714) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
715    reject_missing_event_filter_schema(conn, filter)?;
716
717    let mut conditions: Vec<String> = Vec::new();
718    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
719
720    params.push(Box::new(default_namespace.to_string()));
721    conditions.push(format!("namespace = ?{}", params.len()));
722
723    push_in_clause(
724        &mut conditions,
725        &mut params,
726        "id",
727        filter.ids.iter().map(Uuid::to_string),
728    );
729    push_in_clause(
730        &mut conditions,
731        &mut params,
732        "kind",
733        filter.kinds.iter().map(|kind| kind.name().to_string()),
734    );
735    push_in_clause(
736        &mut conditions,
737        &mut params,
738        "verb",
739        filter.verbs.iter().cloned(),
740    );
741    push_in_clause(
742        &mut conditions,
743        &mut params,
744        "substrate",
745        filter.substrates.iter().map(|s| s.name().to_string()),
746    );
747    push_in_clause(
748        &mut conditions,
749        &mut params,
750        "actor",
751        filter.actors.iter().cloned(),
752    );
753
754    if let Some(after) = filter.after {
755        params.push(Box::new(after));
756        conditions.push(format!("created_at > ?{}", params.len()));
757    }
758
759    if let Some(before) = filter.before {
760        params.push(Box::new(before));
761        conditions.push(format!("created_at < ?{}", params.len()));
762    }
763
764    if let Some(session_id) = filter.session_id {
765        params.push(Box::new(session_id.to_string()));
766        conditions.push(format!("session_id = ?{}", params.len()));
767    }
768
769    push_observation_exists(&mut conditions, &mut params, "candidate", &filter.observed);
770    push_observation_exists(&mut conditions, &mut params, "selected", &filter.selected);
771
772    if let Some(proposal_id) = filter.payload_proposal_id {
773        params.push(Box::new(proposal_id.to_string()));
774        conditions.push(format!(
775            "json_extract(payload, '$.proposal_id') = ?{}",
776            params.len()
777        ));
778    }
779
780    let clause = format!(" WHERE {}", conditions.join(" AND "));
781    Ok((clause, params))
782}
783
784fn push_in_clause<I>(
785    conditions: &mut Vec<String>,
786    params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
787    column: &'static str,
788    values: I,
789) where
790    I: IntoIterator<Item = String>,
791{
792    let placeholders: Vec<String> = values
793        .into_iter()
794        .map(|value| {
795            params.push(Box::new(value));
796            format!("?{}", params.len())
797        })
798        .collect();
799    if !placeholders.is_empty() {
800        conditions.push(format!("{column} IN ({})", placeholders.join(",")));
801    }
802}
803
804fn push_observation_exists(
805    conditions: &mut Vec<String>,
806    params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
807    role: &'static str,
808    entity_ids: &[Uuid],
809) {
810    if entity_ids.is_empty() {
811        return;
812    }
813    let placeholders: Vec<String> = entity_ids
814        .iter()
815        .map(|id| {
816            params.push(Box::new(id.to_string()));
817            format!("?{}", params.len())
818        })
819        .collect();
820    conditions.push(format!(
821        "EXISTS (SELECT 1 FROM event_observations o \
822         WHERE o.event_id = events.id AND o.role = '{role}' AND o.entity_id IN ({}))",
823        placeholders.join(",")
824    ));
825}
826
827fn reject_missing_event_filter_schema(
828    conn: &rusqlite::Connection,
829    filter: &EventFilter,
830) -> Result<(), rusqlite::Error> {
831    if filter.session_id.is_some() && !has_column(conn, "events", "session_id")? {
832        return Err(schema_absent("events.session_id"));
833    }
834    if (!filter.observed.is_empty() || !filter.selected.is_empty())
835        && !has_table(conn, "event_observations")?
836    {
837        return Err(schema_absent("event_observations"));
838    }
839    if filter.payload_proposal_id.is_some() && !has_column(conn, "events", "payload")? {
840        return Err(schema_absent("events.payload"));
841    }
842    Ok(())
843}
844
845fn has_table(conn: &rusqlite::Connection, table: &'static str) -> Result<bool, rusqlite::Error> {
846    conn.query_row(
847        "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
848        [table],
849        |row| row.get(0),
850    )
851}
852
853fn has_column(
854    conn: &rusqlite::Connection,
855    table: &'static str,
856    column: &'static str,
857) -> Result<bool, rusqlite::Error> {
858    conn.query_row(
859        "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
860        rusqlite::params![table, column],
861        |row| row.get(0),
862    )
863}
864
865fn schema_absent(name: &'static str) -> rusqlite::Error {
866    rusqlite::Error::ToSqlConversionFailure(
867        format!("event filter requires missing schema element {name}; run migrations").into(),
868    )
869}
870
871// =============================================================================
872// EventStore implementation
873// =============================================================================
874
875#[async_trait]
876impl EventStore for SqlEventStore {
877    async fn append_event(&self, event: Event) -> Result<(), StorageError> {
878        // ADR-067 Component A: when the write queue is enabled, route
879        // through the pool-wide WriterTask. DML-only closure — no BEGIN
880        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
881        // owns the transaction.
882        if let Some(writer_task) = &self.writer_task {
883            return writer_task
884                .send(move |conn| {
885                    insert_event_with_observations(conn, &event)
886                        .map_err(|e| map_err(e, "append_event"))
887                })
888                .await;
889        }
890
891        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
892        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK.
893        self.with_writer("append_event", move |conn| {
894            conn.execute_batch("BEGIN IMMEDIATE")?;
895            let _tx_handle = khive_storage::tx_registry::register(Some("event_append".to_string()));
896            if let Err(e) = insert_event_with_observations(conn, &event) {
897                let _ = conn.execute_batch("ROLLBACK");
898                return Err(e);
899            }
900            conn.execute_batch("COMMIT")?;
901            Ok(())
902        })
903        .await
904    }
905
906    async fn append_events(&self, events: Vec<Event>) -> Result<BatchWriteSummary, StorageError> {
907        let attempted = events.len() as u64;
908
909        // ADR-067 Component A: when the write queue is enabled, route
910        // through the pool-wide WriterTask. DML-only closure preserving the
911        // all-or-nothing semantics (first failed insert aborts the whole
912        // batch) — the WriterTask's run loop owns the enclosing transaction
913        // and issues the ROLLBACK on `Err`.
914        if let Some(writer_task) = &self.writer_task {
915            return writer_task
916                .send(move |conn| {
917                    batch_append_events_dml(conn, &events, attempted)
918                        .map_err(|e| map_err(e, "append_events"))
919                })
920                .await;
921        }
922
923        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
924        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK.
925        self.with_writer("append_events", move |conn| {
926            conn.execute_batch("BEGIN IMMEDIATE")?;
927            let _tx_handle =
928                khive_storage::tx_registry::register(Some("event_append_batch".to_string()));
929
930            let summary = match batch_append_events_dml(conn, &events, attempted) {
931                Ok(summary) => summary,
932                Err(e) => {
933                    let _ = conn.execute_batch("ROLLBACK");
934                    return Err(e);
935                }
936            };
937
938            conn.execute_batch("COMMIT")?;
939            Ok(summary)
940        })
941        .await
942    }
943
944    async fn get_event(&self, id: Uuid) -> Result<Option<Event>, StorageError> {
945        let namespace = self.namespace.clone();
946        let id_str = id.to_string();
947
948        self.with_reader("get_event", move |conn| {
949            let mut stmt = conn.prepare(
950                "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
951                        payload_schema_version, profile_state_version, duration_us, target_id, \
952                        session_id, aggregate_kind, aggregate_id, created_at \
953                 FROM events WHERE namespace = ?1 AND id = ?2",
954            )?;
955            let mut rows = stmt.query(rusqlite::params![namespace, id_str])?;
956            match rows.next()? {
957                Some(row) => Ok(Some(read_event(row)?)),
958                None => Ok(None),
959            }
960        })
961        .await
962    }
963
964    async fn query_events(
965        &self,
966        filter: EventFilter,
967        page: PageRequest,
968    ) -> Result<Page<Event>, StorageError> {
969        let namespace = self.namespace.clone();
970        let limit_i64 = i64::from(page.limit);
971        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
972            capability: StorageCapability::Events,
973            operation: "query_events".into(),
974            message: format!(
975                "PageRequest: offset must be <= i64::MAX, got {}",
976                page.offset
977            ),
978        })?;
979
980        self.with_reader("query_events", move |conn| {
981            let (where_clause, filter_params) = build_event_filter_sql(conn, &namespace, &filter)?;
982
983            let count_sql = format!("SELECT COUNT(*) FROM events{}", where_clause);
984            let total: i64 = {
985                let mut stmt = conn.prepare(&count_sql)?;
986                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
987                    filter_params.iter().map(|p| p.as_ref()).collect();
988                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
989            };
990
991            let (_, data_filter_params) = build_event_filter_sql(conn, &namespace, &filter)?;
992            let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = data_filter_params;
993            all_params.push(Box::new(limit_i64));
994            all_params.push(Box::new(offset_i64));
995
996            let limit_idx = all_params.len() - 1;
997            let offset_idx = all_params.len();
998
999            let data_sql = format!(
1000                "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
1001                        payload_schema_version, profile_state_version, duration_us, target_id, \
1002                        session_id, aggregate_kind, aggregate_id, created_at \
1003                 FROM events{} ORDER BY created_at DESC, id DESC LIMIT ?{} OFFSET ?{}",
1004                where_clause, limit_idx, offset_idx,
1005            );
1006
1007            let mut stmt = conn.prepare(&data_sql)?;
1008            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1009                all_params.iter().map(|p| p.as_ref()).collect();
1010            let rows = stmt.query_map(param_refs.as_slice(), read_event)?;
1011
1012            let mut items = Vec::new();
1013            for row in rows {
1014                items.push(row?);
1015            }
1016
1017            Ok(Page {
1018                items,
1019                total: Some(total as u64),
1020            })
1021        })
1022        .await
1023    }
1024
1025    async fn count_events(&self, filter: EventFilter) -> Result<u64, StorageError> {
1026        let namespace = self.namespace.clone();
1027
1028        self.with_reader("count_events", move |conn| {
1029            let (where_clause, params) = build_event_filter_sql(conn, &namespace, &filter)?;
1030            let sql = format!("SELECT COUNT(*) FROM events{}", where_clause);
1031            let mut stmt = conn.prepare(&sql)?;
1032            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1033                params.iter().map(|p| p.as_ref()).collect();
1034            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1035            Ok(count as u64)
1036        })
1037        .await
1038    }
1039}
1040
1041// =============================================================================
1042// DDL
1043// =============================================================================
1044
1045const EVENTS_DDL: &str = include_str!("../../sql/events-ddl.sql");
1046
1047pub(crate) fn ensure_events_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1048    conn.execute_batch(EVENTS_DDL)
1049}
1050
1051#[cfg(test)]
1052#[path = "event_tests.rs"]
1053mod tests;