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::collections::BTreeMap;
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use uuid::Uuid;
15
16use khive_storage::error::StorageError;
17#[cfg(test)]
18use khive_storage::error::WriterTaskRequestState;
19use khive_storage::event::{
20    Event, EventAppendDisposition, EventFilter, EventGroupBy, EventObservation,
21    IdempotentEventBatchResult, ObservationRole, ReferentKind,
22};
23use khive_storage::types::{BatchWriteSummary, Page, PageRequest, SqlStatement, SqlValue};
24use khive_storage::EventStore;
25use khive_storage::SqlWriter;
26use khive_storage::StorageCapability;
27use khive_types::{EventKind, EventOutcome, SubstrateKind};
28
29use crate::pool::ConnectionPool;
30use crate::writer_task::WriterTaskHandle;
31
32#[path = "event_cursor.rs"]
33mod cursor;
34
35fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
36    StorageError::driver(StorageCapability::Events, op, e)
37}
38
39// Preserve the existing error-classification fixtures at their original seam.
40#[cfg(test)]
41fn mark_unknown_append_usage(error: &StorageError) {
42    khive_storage::usage::account_event_write(Err(error));
43}
44
45/// An EventStore backed by SQLite tables.
46pub struct SqlEventStore {
47    pool: Arc<ConnectionPool>,
48    is_file_backed: bool,
49    namespace: String,
50    writer_task: Option<WriterTaskHandle>,
51    #[cfg(test)]
52    lose_next_fallback_reply: std::sync::atomic::AtomicBool,
53}
54
55impl SqlEventStore {
56    /// Create a new store scoped to one namespace.
57    pub fn new_scoped(
58        pool: Arc<ConnectionPool>,
59        is_file_backed: bool,
60        namespace: impl Into<String>,
61    ) -> Self {
62        // Enabled by default for file-backed pools; explicit off/degraded
63        // construction remains synchronous (ADR-067 Component A, mirrors
64        // entity.rs policy): a missing writer task is cached without failing
65        // construction. Every write re-resolves it and applies
66        // strict/compatibility policy then.
67        let writer_task = pool.writer_task_handle().ok().flatten();
68        Self {
69            pool,
70            is_file_backed,
71            namespace: namespace.into(),
72            writer_task,
73            #[cfg(test)]
74            lose_next_fallback_reply: std::sync::atomic::AtomicBool::new(false),
75        }
76    }
77
78    #[cfg(test)]
79    fn after_fallback_write_for_test<T>(
80        &self,
81        result: Result<T, StorageError>,
82    ) -> Result<T, StorageError> {
83        // Lose only the reply: the real fallback transaction already committed.
84        if result.is_ok()
85            && self
86                .lose_next_fallback_reply
87                .swap(false, std::sync::atomic::Ordering::SeqCst)
88        {
89            return Err(StorageError::writer_task_terminated(
90                WriterTaskRequestState::SideEffectsUnknown,
91            ));
92        }
93        result
94    }
95
96    fn current_writer_task(
97        &self,
98        operation: &'static str,
99    ) -> Result<Option<WriterTaskHandle>, StorageError> {
100        self.pool
101            .writer_task_for_write(self.writer_task.as_ref(), operation)
102    }
103
104    /// Route a single-row write through the pool-wide `WriterTask` when
105    /// the write queue is enabled and a handle is available. Strict mode
106    /// refuses a missing handle; compatibility mode falls back to the legacy
107    /// standalone-connection / pool-mutex path (ADR-067 Component A, Fork C
108    /// slice 2).
109    ///
110    /// `append_event`/`append_events` perform the same write-time lookup
111    /// first; a non-strict `None` then falls through this helper, which
112    /// records the actual compatibility fallback. Strict mode returns before
113    /// the direct-writer seam.
114    /// `f` must be DML-only on the flag-on path (no bare `BEGIN IMMEDIATE`)
115    /// since it runs inside the WriterTask's own transaction.
116    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
117    where
118        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
119        R: Send + 'static,
120    {
121        if let Some(writer_task) = self.current_writer_task(op)? {
122            return writer_task
123                .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
124                .await;
125        }
126
127        self.pool
128            .record_direct_route(crate::timeout_sink::Site::DirectRouteEventGeneralWrite);
129        // Atomic SQL units release an in-memory connection between statements;
130        // share their unit budget so this transaction cannot overlap one.
131        let unit_slot = if self.is_file_backed {
132            None
133        } else {
134            Some(crate::sql_bridge::acquire_in_memory_write_unit(&self.pool, op).await?)
135        };
136        let pool = Arc::clone(&self.pool);
137        let db = crate::timeout_sink::db_label(&pool);
138        let is_file_backed = self.is_file_backed;
139        tokio::task::spawn_blocking(move || {
140            // Cancellation of the caller must not release the slot before this job ends.
141            let _unit_slot = unit_slot;
142            let result =
143                pool.execute_direct_transaction(StorageCapability::Events, op, move |conn| {
144                    f(conn).map_err(|error| map_err(error, op))
145                });
146            if is_file_backed {
147                if let Err(error) = &result {
148                    crate::timeout_sink::maybe_emit_busy_storage_error(
149                        &db,
150                        crate::timeout_sink::Site::StandaloneEvent,
151                        error,
152                    );
153                }
154            }
155            result
156        })
157        .await
158        .map_err(|e| StorageError::driver(StorageCapability::Events, op, e))?
159    }
160
161    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
162    where
163        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
164        R: Send + 'static,
165    {
166        super::run_pooled_store_read(
167            Arc::clone(&self.pool),
168            StorageCapability::Events,
169            op,
170            move |conn| f(conn).map_err(|error| map_err(error, op)),
171        )
172        .await
173    }
174}
175
176// =============================================================================
177// Helpers: parse SubstrateKind / EventOutcome / EventKind from DB strings
178// =============================================================================
179
180fn substrate_from_str(s: &str) -> Result<SubstrateKind, rusqlite::Error> {
181    s.parse::<SubstrateKind>().map_err(|_| {
182        rusqlite::Error::FromSqlConversionFailure(
183            0,
184            rusqlite::types::Type::Text,
185            format!("unknown SubstrateKind: {s}").into(),
186        )
187    })
188}
189
190fn outcome_from_str(s: &str) -> Result<EventOutcome, rusqlite::Error> {
191    match s {
192        "success" => Ok(EventOutcome::Success),
193        "denied" => Ok(EventOutcome::Denied),
194        "error" => Ok(EventOutcome::Error),
195        other => Err(rusqlite::Error::FromSqlConversionFailure(
196            0,
197            rusqlite::types::Type::Text,
198            format!("unknown EventOutcome: {other}").into(),
199        )),
200    }
201}
202
203fn kind_from_str(s: &str) -> Result<EventKind, rusqlite::Error> {
204    s.parse::<EventKind>().map_err(|_| {
205        rusqlite::Error::FromSqlConversionFailure(
206            0,
207            rusqlite::types::Type::Text,
208            format!("unknown EventKind: {s}").into(),
209        )
210    })
211}
212
213fn referent_kind_from_str(s: &str) -> Result<ReferentKind, rusqlite::Error> {
214    match s {
215        "entity" => Ok(ReferentKind::Entity),
216        "note" => Ok(ReferentKind::Note),
217        "edge" => Ok(ReferentKind::Edge),
218        other => Err(rusqlite::Error::FromSqlConversionFailure(
219            0,
220            rusqlite::types::Type::Text,
221            format!("unknown ReferentKind: {other}").into(),
222        )),
223    }
224}
225
226fn observation_role_from_str(s: &str) -> Result<ObservationRole, rusqlite::Error> {
227    match s {
228        "candidate" => Ok(ObservationRole::Candidate),
229        "selected" => Ok(ObservationRole::Selected),
230        "target" => Ok(ObservationRole::Target),
231        "signal" => Ok(ObservationRole::Signal),
232        other => Err(rusqlite::Error::FromSqlConversionFailure(
233            0,
234            rusqlite::types::Type::Text,
235            format!("unknown ObservationRole: {other}").into(),
236        )),
237    }
238}
239
240fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
241    Uuid::parse_str(s).map_err(|e| {
242        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
243    })
244}
245
246// Column order: id(0), namespace(1), verb(2), substrate(3), actor(4),
247//               kind(5), outcome(6), payload(7), payload_schema_version(8),
248//               profile_state_version(9), duration_us(10), target_id(11),
249//               session_id(12), aggregate_kind(13), aggregate_id(14), created_at(15),
250//               op_index(16), ref_resolution(17)
251fn read_event(row: &rusqlite::Row<'_>) -> Result<Event, rusqlite::Error> {
252    let id_str: String = row.get(0)?;
253    let namespace: String = row.get(1)?;
254    let verb: String = row.get(2)?;
255    let substrate_str: String = row.get(3)?;
256    let actor: String = row.get(4)?;
257    let kind_str: String = row.get(5)?;
258    let outcome_str: String = row.get(6)?;
259    let payload_str: String = row.get(7)?;
260    let payload_schema_version: i64 = row.get(8)?;
261    let profile_state_version: Option<i64> = row.get(9)?;
262    let duration_us: i64 = row.get(10)?;
263    let target_str: Option<String> = row.get(11)?;
264    let session_str: Option<String> = row.get(12)?;
265    let aggregate_kind: Option<String> = row.get(13)?;
266    let aggregate_str: Option<String> = row.get(14)?;
267    let created_at: i64 = row.get(15)?;
268    let op_index = row.get::<_, Option<u32>>(16)?;
269    let ref_resolution = row
270        .get::<_, Option<String>>(17)?
271        .map(|value| {
272            value
273                .parse::<khive_types::RefResolution>()
274                .map_err(|error| {
275                    rusqlite::Error::FromSqlConversionFailure(
276                        17,
277                        rusqlite::types::Type::Text,
278                        Box::new(error),
279                    )
280                })
281        })
282        .transpose()?;
283    if op_index.is_some() != ref_resolution.is_some() {
284        return Err(rusqlite::Error::FromSqlConversionFailure(
285            16,
286            rusqlite::types::Type::Integer,
287            "event operation attribution must be present or absent together".into(),
288        ));
289    }
290
291    let id = parse_uuid(&id_str)?;
292    let substrate = substrate_from_str(&substrate_str)?;
293    let kind = kind_from_str(&kind_str)?;
294    let outcome = outcome_from_str(&outcome_str)?;
295    let payload: serde_json::Value = serde_json::from_str(&payload_str).map_err(|e| {
296        rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
297    })?;
298    let target_id = target_str.as_deref().map(parse_uuid).transpose()?;
299    let session_id = session_str.as_deref().map(parse_uuid).transpose()?;
300    let aggregate_id = aggregate_str.as_deref().map(parse_uuid).transpose()?;
301    let payload_schema_version_u32: u32 = payload_schema_version.try_into().map_err(|_| {
302        rusqlite::Error::FromSqlConversionFailure(
303            8,
304            rusqlite::types::Type::Integer,
305            format!("payload_schema_version {payload_schema_version} out of u32 range").into(),
306        )
307    })?;
308    let profile_state_version_u64: Option<u64> = profile_state_version
309        .map(|v| {
310            u64::try_from(v).map_err(|_| {
311                rusqlite::Error::FromSqlConversionFailure(
312                    9,
313                    rusqlite::types::Type::Integer,
314                    format!("profile_state_version {v} out of u64 range").into(),
315                )
316            })
317        })
318        .transpose()?;
319
320    Ok(Event {
321        id,
322        namespace,
323        verb,
324        substrate,
325        actor,
326        kind,
327        outcome,
328        payload,
329        payload_schema_version: payload_schema_version_u32,
330        profile_state_version: profile_state_version_u64,
331        duration_us,
332        target_id,
333        session_id,
334        aggregate_kind,
335        aggregate_id,
336        created_at,
337        op_index,
338        ref_resolution,
339    })
340}
341
342// =============================================================================
343// Helpers: observation projection write path
344// =============================================================================
345
346fn insert_event_with_observations(
347    conn: &rusqlite::Connection,
348    event: &Event,
349) -> Result<(), rusqlite::Error> {
350    validate_operation_pair(event)?;
351    let id_str = event.id.to_string();
352    let substrate_str = event.substrate.name().to_string();
353    let kind_str = event.kind.name().to_string();
354    let outcome_str = event.outcome.name().to_string();
355    let payload_str = event.payload.to_string();
356    let target_str = event.target_id.map(|u| u.to_string());
357    let session_str = event.session_id.map(|u| u.to_string());
358    let aggregate_str = event.aggregate_id.map(|u| u.to_string());
359    let profile_state_version = profile_state_version_to_sql(event)?;
360
361    conn.execute(
362        "INSERT INTO events \
363         (id, namespace, verb, substrate, actor, kind, outcome, payload, payload_schema_version, \
364          profile_state_version, duration_us, target_id, session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution) \
365         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
366        rusqlite::params![
367            id_str,
368            &event.namespace,
369            &event.verb,
370            substrate_str,
371            &event.actor,
372            kind_str,
373            outcome_str,
374            payload_str,
375            event.payload_schema_version as i64,
376            profile_state_version,
377            event.duration_us,
378            target_str,
379            session_str,
380            &event.aggregate_kind,
381            aggregate_str,
382            event.created_at,
383            event.op_index,
384            event.ref_resolution.map(|value| value.name()),
385        ],
386    )?;
387
388    for observation in decode_event_observations(event)? {
389        conn.execute(
390            "INSERT INTO event_observations \
391             (event_id, entity_id, referent_kind, role, position) \
392             VALUES (?1, ?2, ?3, ?4, ?5)",
393            rusqlite::params![
394                observation.event_id.to_string(),
395                observation.entity_id.to_string(),
396                observation.referent_kind.name(),
397                observation.role.name(),
398                observation.position as i64,
399            ],
400        )?;
401    }
402
403    Ok(())
404}
405
406/// Append an event on the caller's existing SQLite transaction, including the
407/// same observation projection as `SqlEventStore::append_event`. This does
408/// not open or commit a transaction: the caller must roll back its domain
409/// changes if this insert fails.
410pub fn append_event_in_transaction(
411    conn: &rusqlite::Connection,
412    event: &Event,
413) -> Result<(), rusqlite::Error> {
414    insert_event_with_observations(conn, event)
415}
416
417/// DML-only batch append loop shared by both the legacy (flag-off) and
418/// WriterTask-routed (flag-on) `append_events` paths (ADR-067 Component A).
419///
420/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
421/// enclosing transaction. All-or-nothing: the first failed insert returns
422/// `Err` immediately (matching the pre-existing `append_events` contract) —
423/// the caller's transaction wrapper issues the ROLLBACK.
424fn batch_append_events_dml(
425    conn: &rusqlite::Connection,
426    events: &[Event],
427    attempted: u64,
428) -> Result<BatchWriteSummary, rusqlite::Error> {
429    let mut affected = 0u64;
430    for event in events {
431        insert_event_with_observations(conn, event)?;
432        affected += 1;
433    }
434    Ok(BatchWriteSummary {
435        attempted,
436        affected,
437        ..BatchWriteSummary::default()
438    })
439}
440
441fn fetch_event_by_id(
442    conn: &rusqlite::Connection,
443    id: Uuid,
444) -> Result<Option<Event>, rusqlite::Error> {
445    let id_str = id.to_string();
446    let mut stmt = conn.prepare(
447        "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
448                payload_schema_version, profile_state_version, duration_us, target_id, \
449                session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution \
450         FROM events WHERE id = ?1",
451    )?;
452    let mut rows = stmt.query(rusqlite::params![id_str])?;
453    match rows.next()? {
454        Some(row) => Ok(Some(read_event(row)?)),
455        None => Ok(None),
456    }
457}
458
459/// Fetch `event_id`'s persisted observation projection in the same order it
460/// was written: `event_observations`'s primary key is `(event_id, role,
461/// position)`, and every `decode_*_observations` builder emits one role
462/// group at a time in a fixed sequence that sorts identically by `role`
463/// text, so `ORDER BY role, position` reproduces the original emission
464/// order without a separate sequence column.
465fn fetch_event_observations(
466    conn: &rusqlite::Connection,
467    event_id: Uuid,
468) -> Result<Vec<EventObservation>, rusqlite::Error> {
469    let id_str = event_id.to_string();
470    let mut stmt = conn.prepare(
471        "SELECT event_id, entity_id, referent_kind, role, position \
472         FROM event_observations WHERE event_id = ?1 ORDER BY role, position",
473    )?;
474    let rows = stmt.query_map(rusqlite::params![id_str], |row| {
475        let event_id: String = row.get(0)?;
476        let entity_id: String = row.get(1)?;
477        let referent_kind: String = row.get(2)?;
478        let role: String = row.get(3)?;
479        let position: i64 = row.get(4)?;
480        Ok((event_id, entity_id, referent_kind, role, position))
481    })?;
482
483    let mut out = Vec::new();
484    for row in rows {
485        let (event_id, entity_id, referent_kind, role, position) = row?;
486        let position_u32: u32 = position.try_into().map_err(|_| {
487            rusqlite::Error::FromSqlConversionFailure(
488                4,
489                rusqlite::types::Type::Integer,
490                format!("position {position} out of u32 range").into(),
491            )
492        })?;
493        out.push(EventObservation {
494            event_id: parse_uuid(&event_id)?,
495            entity_id: parse_uuid(&entity_id)?,
496            referent_kind: referent_kind_from_str(&referent_kind)?,
497            role: observation_role_from_str(&role)?,
498            position: position_u32,
499        });
500    }
501    Ok(out)
502}
503
504/// DML-only idempotent batch loop shared by both the writer-task-routed and
505/// standalone-transaction `append_events_idempotent` paths, mirroring
506/// [`batch_append_events_dml`]'s split. A row whose id is unseen is
507/// inserted; a row whose id already exists is compared column-for-column
508/// plus its ordered observation projection against the submitted event —
509/// exact equality reports `AlreadyPresentIdentical` and skips the write,
510/// any mismatch reports `IdentityConflict` and skips the write for that row
511/// alone. A genuine store failure on a fresh insert still aborts the whole
512/// batch via `Err`, matching `append_events`'s all-or-nothing contract; only
513/// identity comparison is per-row.
514fn idempotent_batch_dml(
515    conn: &rusqlite::Connection,
516    events: &[Event],
517) -> Result<IdempotentEventBatchResult, rusqlite::Error> {
518    let mut rows = Vec::with_capacity(events.len());
519    for event in events {
520        validate_operation_pair(event)?;
521        match fetch_event_by_id(conn, event.id)? {
522            None => {
523                insert_event_with_observations(conn, event)?;
524                rows.push(EventAppendDisposition::Inserted);
525            }
526            Some(existing) => {
527                let existing_observations = fetch_event_observations(conn, event.id)?;
528                let submitted_observations = decode_event_observations(event)?;
529                if existing == *event && existing_observations == submitted_observations {
530                    rows.push(EventAppendDisposition::AlreadyPresentIdentical);
531                } else {
532                    rows.push(EventAppendDisposition::IdentityConflict);
533                }
534            }
535        }
536    }
537    Ok(IdempotentEventBatchResult { rows })
538}
539
540/// Pure statement builder (ADR-099 B3 r6 structural cut): the exact
541/// `events` row insert plus every derived `event_observations` insert for
542/// `event`, as plain [`SqlStatement`]s with no I/O. This is the ONE place
543/// that knows the `events`/`event_observations` insert shape at the
544/// statement-text level; both [`append_event_on_writer`] below (the async
545/// `SqlWriter` execution path) and `khive-runtime`'s ADR-099 `--atomic`
546/// prepare path (which needs plain-data statements it can fold into a
547/// synchronous commit-phase plan, not an async writer call) build on this
548/// function rather than each hand-writing the INSERT text — the divergence
549/// that produced the drift this cut fixes.
550pub fn event_insert_statements(event: &Event) -> Result<Vec<SqlStatement>, rusqlite::Error> {
551    validate_operation_pair(event)?;
552    let id_str = event.id.to_string();
553    let substrate_str = event.substrate.name().to_string();
554    let kind_str = event.kind.name().to_string();
555    let outcome_str = event.outcome.name().to_string();
556    let payload_str = event.payload.to_string();
557    let target_str = event.target_id.map(|u| u.to_string());
558    let session_str = event.session_id.map(|u| u.to_string());
559    let aggregate_str = event.aggregate_id.map(|u| u.to_string());
560    let profile_state_version = profile_state_version_to_sql(event)?;
561
562    let mut statements = vec![SqlStatement {
563        sql: "INSERT INTO events \
564              (id, namespace, verb, substrate, actor, kind, outcome, payload, payload_schema_version, \
565               profile_state_version, duration_us, target_id, session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution) \
566              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"
567            .into(),
568        params: vec![
569            SqlValue::Text(id_str),
570            SqlValue::Text(event.namespace.clone()),
571            SqlValue::Text(event.verb.clone()),
572            SqlValue::Text(substrate_str),
573            SqlValue::Text(event.actor.clone()),
574            SqlValue::Text(kind_str),
575            SqlValue::Text(outcome_str),
576            SqlValue::Text(payload_str),
577            SqlValue::Integer(event.payload_schema_version as i64),
578            profile_state_version
579                .map(SqlValue::Integer)
580                .unwrap_or(SqlValue::Null),
581            SqlValue::Integer(event.duration_us),
582            target_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
583            session_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
584            event
585                .aggregate_kind
586                .clone()
587                .map(SqlValue::Text)
588                .unwrap_or(SqlValue::Null),
589            aggregate_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
590            SqlValue::Integer(event.created_at),
591            event.op_index.map(|value| SqlValue::Integer(i64::from(value))).unwrap_or(SqlValue::Null),
592            event.ref_resolution.map(|value| SqlValue::Text(value.name().into())).unwrap_or(SqlValue::Null),
593        ],
594        label: Some("event_insert_on_writer".into()),
595    }];
596
597    for observation in decode_event_observations(event)? {
598        statements.push(SqlStatement {
599            sql: "INSERT INTO event_observations \
600                  (event_id, entity_id, referent_kind, role, position) \
601                  VALUES (?1, ?2, ?3, ?4, ?5)"
602                .into(),
603            params: vec![
604                SqlValue::Text(observation.event_id.to_string()),
605                SqlValue::Text(observation.entity_id.to_string()),
606                SqlValue::Text(observation.referent_kind.name().to_string()),
607                SqlValue::Text(observation.role.name().to_string()),
608                SqlValue::Integer(observation.position as i64),
609            ],
610            label: Some("event_observation_insert_on_writer".into()),
611        });
612    }
613
614    Ok(statements)
615}
616
617fn validate_operation_pair(event: &Event) -> Result<(), rusqlite::Error> {
618    if event.op_index.is_some() != event.ref_resolution.is_some() {
619        return Err(rusqlite::Error::ToSqlConversionFailure(
620            "event operation attribution must be present or absent together".into(),
621        ));
622    }
623    profile_state_version_to_sql(event)?;
624    Ok(())
625}
626
627fn profile_state_version_to_sql(event: &Event) -> Result<Option<i64>, rusqlite::Error> {
628    event
629        .profile_state_version
630        .map(|version| {
631            i64::try_from(version).map_err(|_| {
632                rusqlite::Error::ToSqlConversionFailure(
633                    format!("profile_state_version {version} exceeds i64::MAX").into(),
634                )
635            })
636        })
637        .transpose()
638}
639
640/// Build commit-time warning inserts for lineage-sensitive incident edges
641/// removed by a hard-delete cascade (ADR-002).
642///
643/// Each statement handles one protected relation and reads `graph_edges`
644/// when it executes, inside the caller's delete transaction. This is
645/// intentionally not a prepare-time edge query: a guarded edge write that
646/// commits immediately before the delete must be represented in the warning
647/// payload before the subsequent cascade statement removes it. Relations
648/// with no matching incident edge insert no event. Incident edges are found
649/// in every namespace through the graph store's namespace walk, so each
650/// statement seeks per namespace instead of reading every edge.
651pub fn hard_delete_lineage_warning_statements(
652    namespace: &str,
653    actor: &str,
654    target_id: Uuid,
655    substrate: SubstrateKind,
656) -> Vec<SqlStatement> {
657    const WARNINGS: [(&str, &str); 5] = [
658        ("derived_from", "provenance_loss"),
659        ("supersedes", "replacement_lineage_loss"),
660        ("precedes", "temporal_sequence_loss"),
661        ("supports", "evidential_link_loss"),
662        ("refutes", "evidential_link_loss"),
663    ];
664
665    let target_id = target_id.to_string();
666    let created_at = chrono::Utc::now().timestamp_micros();
667    let operation = khive_storage::operation_context::current_operation_attribution();
668    WARNINGS
669        .into_iter()
670        .map(|(relation, warning)| SqlStatement {
671            sql: format!(
672                "INSERT INTO events \
673                  (id, namespace, verb, substrate, actor, kind, outcome, payload, \
674                   payload_schema_version, profile_state_version, duration_us, target_id, \
675                   session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution) \
676                  SELECT ?1, ?2, 'delete', ?3, ?4, ?5, ?6, \
677                         json_object( \
678                           'severity', 'warning', \
679                           'warning', ?7, \
680                           'deleted_id', ?8, \
681                           'relation', ?9, \
682                           'edge_count', count(*), \
683                           'edges', json_group_array(json_object( \
684                             'id', incident.id, \
685                             'namespace', incident.namespace, \
686                             'source_id', incident.source_id, \
687                             'target_id', incident.target_id, \
688                             'deleted_at', incident.deleted_at)) \
689                         ), \
690                         1, NULL, 0, ?8, NULL, NULL, NULL, ?10, ?11, ?12 \
691                  FROM ( {cte} \
692                    SELECT e.id AS id, e.namespace AS namespace, e.source_id AS source_id, \
693                           e.target_id AS target_id, e.deleted_at AS deleted_at \
694                    FROM edge_ns CROSS JOIN graph_edges AS e \
695                      ON e.namespace = edge_ns.namespace AND e.source_id = ?8 AND e.relation = ?9 \
696                    UNION ALL \
697                    SELECT e.id AS id, e.namespace AS namespace, e.source_id AS source_id, \
698                           e.target_id AS target_id, e.deleted_at AS deleted_at \
699                    FROM edge_ns CROSS JOIN graph_edges AS e \
700                      ON e.namespace = edge_ns.namespace AND e.target_id = ?8 AND e.relation = ?9 \
701                    WHERE e.source_id <> ?8 \
702                    ORDER BY namespace, id \
703                  ) AS incident \
704                  HAVING count(*) > 0",
705                cte = super::graph::EDGE_NAMESPACES_CTE
706            ),
707            params: vec![
708                SqlValue::Text(Uuid::new_v4().to_string()),
709                SqlValue::Text(namespace.to_string()),
710                SqlValue::Text(substrate.name().to_string()),
711                SqlValue::Text(actor.to_string()),
712                SqlValue::Text(EventKind::Audit.name().to_string()),
713                SqlValue::Text(EventOutcome::Success.name().to_string()),
714                SqlValue::Text(warning.to_string()),
715                SqlValue::Text(target_id.clone()),
716                SqlValue::Text(relation.to_string()),
717                SqlValue::Integer(created_at),
718                operation
719                    .map(|value| SqlValue::Integer(i64::from(value.op_index)))
720                    .unwrap_or(SqlValue::Null),
721                operation
722                    .map(|value| SqlValue::Text(value.ref_resolution.name().into()))
723                    .unwrap_or(SqlValue::Null),
724            ],
725            label: Some(format!("hard-delete-{relation}-warning")),
726        })
727        .collect()
728}
729
730/// Insert `event` (and any derived `event_observations` rows) through the
731/// `khive-storage` `SqlWriter` seam, on a transaction the CALLER already
732/// opened and controls the commit/rollback boundary for. Issues no
733/// `BEGIN`/`COMMIT`/`ROLLBACK` of its own.
734///
735/// This exists alongside `insert_event_with_observations` (the raw
736/// `rusqlite::Connection` path `SqlEventStore::append_event` uses, whose
737/// caller now owns an admitted transaction for the ordinary case) rather
738/// than replacing it: the two run on
739/// different connection abstractions — a standalone `rusqlite::Connection`
740/// vs. a `Box<dyn SqlWriter>` — so they cannot share one function body. Both
741/// build on [`event_insert_statements`] for the actual insert shape.
742/// Callers that need the event append to be part of a larger atomic unit —
743/// e.g. ADR-081's brain fold gate (`khive-pack-brain/src/fold_gate.rs`),
744/// which holds its own `BEGIN IMMEDIATE` transaction on a `SqlWriter` for
745/// the dedup claim + mass fold and needs the feedback event to land in that
746/// same transaction — call this instead of duplicating the insert shape
747/// into their own crate.
748pub async fn append_event_on_writer(
749    writer: &mut dyn SqlWriter,
750    event: &Event,
751) -> Result<(), StorageError> {
752    let statements =
753        event_insert_statements(event).map_err(|e| map_err(e, "decode_event_observations"))?;
754    for statement in statements {
755        writer.execute(statement).await?;
756    }
757    Ok(())
758}
759
760fn decode_event_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
761    match event.kind {
762        EventKind::RerankExecuted => decode_rerank_observations(event),
763        EventKind::RecallExecuted => decode_recall_observations(event),
764        EventKind::SearchExecuted => decode_search_observations(event),
765        EventKind::LinkCreated => decode_link_observations(event),
766        EventKind::EdgeUpdated | EventKind::EdgeDeleted => decode_edge_target_observation(event),
767        EventKind::EntityCreated
768        | EventKind::EntityUpdated
769        | EventKind::EntityDeleted
770        | EventKind::NoteCreated
771        | EventKind::NoteUpdated
772        | EventKind::NoteDeleted
773        | EventKind::TaskTransitioned => decode_target_observation(event),
774        EventKind::FeedbackExplicit => decode_signal_observation(event),
775        _ => Ok(Vec::new()),
776    }
777}
778
779fn payload_uuid_array(event: &Event, field: &'static str) -> Result<Vec<Uuid>, rusqlite::Error> {
780    Ok(payload_uuid_array_opt(event, field)?.unwrap_or_default())
781}
782
783/// Like [`payload_uuid_array`], but distinguishes an absent `field` (`Ok(None)`)
784/// from a present-but-malformed one (`Err`). Callers that fall back across a
785/// chain of alternative field names (e.g. `decode_rerank_observations`'s
786/// `final_scores`/`reranked`) need that distinction: collapsing
787/// "missing" into `Ok(vec![])` makes every later field in the chain
788/// unreachable, since `Result::or_else` only fires on `Err`.
789fn payload_uuid_array_opt(
790    event: &Event,
791    field: &'static str,
792) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
793    let Some(values) = event.payload.get(field) else {
794        return Ok(None);
795    };
796    let Some(array) = values.as_array() else {
797        return Err(invalid_payload(event.kind, field, "expected array"));
798    };
799
800    array
801        .iter()
802        .map(|value| {
803            value
804                .as_str()
805                .ok_or_else(|| invalid_payload(event.kind, field, "expected UUID string"))
806                .and_then(|s| Uuid::parse_str(s).map_err(|e| invalid_payload(event.kind, field, e)))
807        })
808        .collect::<Result<Vec<_>, _>>()
809        .map(Some)
810}
811
812/// Decode `RerankExecutedPayload::final_scores` (`Vec<(Id128, f32)>` per
813/// `khive_types::event::RerankExecutedPayload`) into the UUID leading each
814/// tuple. Deserializes through that exact typed tuple shape, so a tuple that
815/// is not precisely two elements, or whose second element is not a finite
816/// score, is rejected rather than silently accepted. Absent field is
817/// `Ok(None)`; present-but-wrong-shape is `Err`, matching
818/// [`payload_uuid_array_opt`]'s missing-vs-malformed contract.
819fn payload_final_scores_uuid_array_opt(
820    event: &Event,
821    field: &'static str,
822) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
823    let Some(values) = event.payload.get(field) else {
824        return Ok(None);
825    };
826    let tuples: Vec<(khive_types::Id128, f32)> = serde_json::from_value(values.clone())
827        .map_err(|e| invalid_payload(event.kind, field, e))?;
828    tuples
829        .into_iter()
830        .map(|(id, score)| {
831            if !score.is_finite() {
832                return Err(invalid_payload(event.kind, field, "score is not finite"));
833            }
834            Ok(Uuid::from_bytes(*id.as_bytes()))
835        })
836        .collect::<Result<Vec<_>, _>>()
837        .map(Some)
838}
839
840/// Decode `RerankExecutedPayload::reranked` (`Vec<(Id128, Vec<(String, f32)>)>`
841/// per `khive_types::event::RerankExecutedPayload`) into the UUID leading each
842/// tuple. Same exact-shape decoding contract as
843/// [`payload_final_scores_uuid_array_opt`], applied to `reranked`'s
844/// per-reranker sub-score shape instead of a single scalar score.
845fn payload_reranked_uuid_array_opt(
846    event: &Event,
847    field: &'static str,
848) -> Result<Option<Vec<Uuid>>, rusqlite::Error> {
849    let Some(values) = event.payload.get(field) else {
850        return Ok(None);
851    };
852    let tuples: Vec<(khive_types::Id128, Vec<(String, f32)>)> =
853        serde_json::from_value(values.clone())
854            .map_err(|e| invalid_payload(event.kind, field, e))?;
855    tuples
856        .into_iter()
857        .map(|(id, scores)| {
858            if !scores.iter().all(|(_, s)| s.is_finite()) {
859                return Err(invalid_payload(event.kind, field, "score is not finite"));
860            }
861            Ok(Uuid::from_bytes(*id.as_bytes()))
862        })
863        .collect::<Result<Vec<_>, _>>()
864        .map(Some)
865}
866
867fn payload_uuid(event: &Event, field: &'static str) -> Result<Option<Uuid>, rusqlite::Error> {
868    let Some(value) = event.payload.get(field) else {
869        return Ok(None);
870    };
871    let Some(s) = value.as_str() else {
872        return Err(invalid_payload(event.kind, field, "expected UUID string"));
873    };
874    Uuid::parse_str(s)
875        .map(Some)
876        .map_err(|e| invalid_payload(event.kind, field, e))
877}
878
879fn decode_candidate_observations(
880    event: &Event,
881    referent_kind: ReferentKind,
882) -> Result<Vec<EventObservation>, rusqlite::Error> {
883    let mut rows = Vec::new();
884
885    for (position, entity_id) in payload_uuid_array(event, "candidates")?
886        .into_iter()
887        .enumerate()
888    {
889        let position_u32 = u32::try_from(position).map_err(|_| {
890            invalid_payload(
891                event.kind,
892                "candidates[position]",
893                "position out of u32 range",
894            )
895        })?;
896        rows.push(EventObservation {
897            event_id: event.id,
898            entity_id,
899            referent_kind,
900            role: ObservationRole::Candidate,
901            position: position_u32,
902        });
903    }
904
905    Ok(rows)
906}
907
908fn push_selected_observations(
909    event: &Event,
910    selected: Vec<Uuid>,
911    referent_kind: ReferentKind,
912    rows: &mut Vec<EventObservation>,
913) -> Result<(), rusqlite::Error> {
914    for (position, entity_id) in selected.into_iter().enumerate() {
915        let position_u32 = u32::try_from(position).map_err(|_| {
916            invalid_payload(
917                event.kind,
918                "selected[position]",
919                "position out of u32 range",
920            )
921        })?;
922        rows.push(EventObservation {
923            event_id: event.id,
924            entity_id,
925            referent_kind,
926            role: ObservationRole::Selected,
927            position: position_u32,
928        });
929    }
930    Ok(())
931}
932
933/// `RecallExecuted` payloads carry flat candidate and selected note UUID lists.
934fn decode_recall_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
935    let mut rows = decode_candidate_observations(event, ReferentKind::Note)?;
936    let selected = payload_uuid_array_opt(event, "selected")?.unwrap_or_default();
937    push_selected_observations(event, selected, ReferentKind::Note, &mut rows)?;
938    Ok(rows)
939}
940
941/// `SearchExecuted.result_kind` identifies which substrate owns every UUID in
942/// the candidate and selected lists. A missing key is the historical note
943/// shape under ADR-041 A3; present unknown or non-string values are invalid.
944fn decode_search_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
945    if event.payload.as_object().is_none() {
946        return Err(invalid_payload(event.kind, "payload", "expected object"));
947    }
948    let referent_kind = match event.payload.get("result_kind") {
949        Some(serde_json::Value::String(kind)) if kind == "entity" => ReferentKind::Entity,
950        Some(serde_json::Value::String(kind)) if kind == "note" => ReferentKind::Note,
951        Some(serde_json::Value::String(_)) => {
952            return Err(invalid_payload(
953                event.kind,
954                "result_kind",
955                "expected \"entity\" or \"note\"",
956            ));
957        }
958        Some(_) => {
959            return Err(invalid_payload(
960                event.kind,
961                "result_kind",
962                "expected string \"entity\" or \"note\"",
963            ));
964        }
965        None => ReferentKind::Note,
966    };
967    let mut rows = decode_candidate_observations(event, referent_kind)?;
968    let selected = payload_uuid_array_opt(event, "selected")?.unwrap_or_default();
969    push_selected_observations(event, selected, referent_kind, &mut rows)?;
970    Ok(rows)
971}
972
973/// `RerankExecutedPayload` (`khive_types::event::RerankExecutedPayload`) has no
974/// `selected` field at all — a stray `selected` key in the raw payload is not
975/// part of its typed contract and must never be consulted. Per ADR-042 §5,
976/// `final_scores` is the ordered rerank output (positions match output
977/// order); `reranked` is per-reranker audit/debug data with no ordering
978/// guarantee, so it is only a legacy fallback for events emitted before
979/// `final_scores` existed. `reranked` is tried only when `final_scores` is
980/// absent (`None`) — a present-but-malformed `final_scores` errors
981/// immediately instead of masking the problem by falling through.
982fn decode_rerank_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
983    let mut rows = decode_candidate_observations(event, ReferentKind::Note)?;
984    let selected = payload_final_scores_uuid_array_opt(event, "final_scores")?
985        .or(payload_reranked_uuid_array_opt(event, "reranked")?)
986        .unwrap_or_default();
987    push_selected_observations(event, selected, ReferentKind::Note, &mut rows)?;
988
989    Ok(rows)
990}
991
992fn decode_link_observations(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
993    let mut rows = Vec::new();
994    let source_kind = payload_link_referent_kind(event, "source_kind")?;
995    let target_kind = payload_link_referent_kind(event, "target_kind")?;
996    if let Some(source) = payload_uuid(event, "source_id")? {
997        if let Some(referent_kind) = source_kind {
998            rows.push(EventObservation {
999                event_id: event.id,
1000                entity_id: source,
1001                referent_kind,
1002                role: ObservationRole::Target,
1003                position: 0,
1004            });
1005        }
1006    }
1007    if let Some(target) = payload_uuid(event, "target_id")? {
1008        if let Some(referent_kind) = target_kind {
1009            rows.push(EventObservation {
1010                event_id: event.id,
1011                entity_id: target,
1012                referent_kind,
1013                role: ObservationRole::Target,
1014                position: 1,
1015            });
1016        }
1017    }
1018    if let Some(edge_id) = event.target_id.or(payload_uuid(event, "id")?) {
1019        rows.push(EventObservation {
1020            event_id: event.id,
1021            entity_id: edge_id,
1022            referent_kind: ReferentKind::Edge,
1023            role: ObservationRole::Target,
1024            position: 2,
1025        });
1026    }
1027    Ok(rows)
1028}
1029
1030fn payload_link_referent_kind(
1031    event: &Event,
1032    field: &'static str,
1033) -> Result<Option<ReferentKind>, rusqlite::Error> {
1034    match event.payload.get(field) {
1035        None => Ok(Some(ReferentKind::Entity)),
1036        Some(value) => match value.as_str() {
1037            Some("entity") => Ok(Some(ReferentKind::Entity)),
1038            Some("note") => Ok(Some(ReferentKind::Note)),
1039            Some("edge") => Ok(Some(ReferentKind::Edge)),
1040            Some("event") => Ok(None),
1041            _ => Err(invalid_payload(
1042                event.kind,
1043                field,
1044                "expected \"entity\", \"note\", \"edge\", or \"event\"",
1045            )),
1046        },
1047    }
1048}
1049
1050fn decode_edge_target_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
1051    let Some(edge_id) = event.target_id.or(payload_uuid(event, "id")?) else {
1052        return Ok(Vec::new());
1053    };
1054    Ok(vec![EventObservation {
1055        event_id: event.id,
1056        entity_id: edge_id,
1057        referent_kind: ReferentKind::Edge,
1058        role: ObservationRole::Target,
1059        position: 0,
1060    }])
1061}
1062
1063fn decode_target_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
1064    let Some(entity_id) = event.target_id.or(payload_uuid(event, "target_id")?) else {
1065        return Ok(Vec::new());
1066    };
1067    Ok(vec![EventObservation {
1068        event_id: event.id,
1069        entity_id,
1070        referent_kind: if event.substrate == SubstrateKind::Note {
1071            ReferentKind::Note
1072        } else {
1073            ReferentKind::Entity
1074        },
1075        role: ObservationRole::Target,
1076        position: 0,
1077    }])
1078}
1079
1080fn decode_signal_observation(event: &Event) -> Result<Vec<EventObservation>, rusqlite::Error> {
1081    let Some(entity_id) = event.target_id else {
1082        return Ok(Vec::new());
1083    };
1084    Ok(vec![EventObservation {
1085        event_id: event.id,
1086        entity_id,
1087        // ADR-041 permits both entity and note signal targets. `brain.feedback`
1088        // threads the resolved target's substrate onto the event (#831);
1089        // pre-fix events all carry the old
1090        // `SubstrateKind::Event` placeholder and fall back to Entity, matching
1091        // the decoder's prior hard-coded behavior for that historical data.
1092        referent_kind: if event.substrate == SubstrateKind::Note {
1093            ReferentKind::Note
1094        } else {
1095            ReferentKind::Entity
1096        },
1097        role: ObservationRole::Signal,
1098        position: 0,
1099    }])
1100}
1101
1102fn invalid_payload(
1103    kind: EventKind,
1104    field: &'static str,
1105    reason: impl std::fmt::Display,
1106) -> rusqlite::Error {
1107    rusqlite::Error::ToSqlConversionFailure(
1108        format!("invalid payload for {}.{field}: {reason}", kind.name()).into(),
1109    )
1110}
1111
1112// =============================================================================
1113// Helpers: filter SQL builder
1114// =============================================================================
1115
1116fn build_event_filter_sql(
1117    conn: &rusqlite::Connection,
1118    default_namespace: &str,
1119    filter: &EventFilter,
1120) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), rusqlite::Error> {
1121    reject_missing_event_filter_schema(conn, filter)?;
1122
1123    let mut conditions: Vec<String> = Vec::new();
1124    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1125
1126    params.push(Box::new(default_namespace.to_string()));
1127    // An ID-filtered read is bounded by the caller's IDs. Without
1128    // sqlite_stat1 the planner otherwise lets the namespace equality drive
1129    // through the index that also serves `ORDER BY created_at DESC, id DESC`,
1130    // and walks every row of the namespace whenever fewer than `LIMIT` of
1131    // the IDs exist, which is the normal case for the split store's probe of a
1132    // new audit batch. Unary `+` keeps the namespace term as a filter only, so
1133    // the primary key drives.
1134    let namespace_term = if filter.ids.is_empty() {
1135        "namespace"
1136    } else {
1137        "+namespace"
1138    };
1139    conditions.push(format!("{namespace_term} = ?{}", params.len()));
1140
1141    push_in_clause(
1142        &mut conditions,
1143        &mut params,
1144        "id",
1145        filter.ids.iter().map(Uuid::to_string),
1146    );
1147    push_in_clause(
1148        &mut conditions,
1149        &mut params,
1150        "kind",
1151        filter.kinds.iter().map(|kind| kind.name().to_string()),
1152    );
1153    push_in_clause(
1154        &mut conditions,
1155        &mut params,
1156        "verb",
1157        filter.verbs.iter().cloned(),
1158    );
1159    push_in_clause(
1160        &mut conditions,
1161        &mut params,
1162        "substrate",
1163        filter.substrates.iter().map(|s| s.name().to_string()),
1164    );
1165    push_in_clause(
1166        &mut conditions,
1167        &mut params,
1168        "actor",
1169        filter.actors.iter().cloned(),
1170    );
1171
1172    if let Some(outcome) = filter.outcome {
1173        params.push(Box::new(outcome.name().to_string()));
1174        conditions.push(format!("outcome = ?{}", params.len()));
1175    }
1176    super::append_json_equalities(
1177        &mut conditions,
1178        &mut params,
1179        "payload",
1180        &filter.payload_equalities,
1181    );
1182
1183    if let Some(after) = filter.after {
1184        params.push(Box::new(after));
1185        conditions.push(format!("created_at > ?{}", params.len()));
1186    }
1187
1188    if let Some(before) = filter.before {
1189        params.push(Box::new(before));
1190        conditions.push(format!("created_at < ?{}", params.len()));
1191    }
1192
1193    if let Some(target_id) = filter.target_id {
1194        params.push(Box::new(target_id.to_string()));
1195        conditions.push(format!("target_id = ?{}", params.len()));
1196    }
1197
1198    if let Some(session_id) = filter.session_id {
1199        params.push(Box::new(session_id.to_string()));
1200        conditions.push(format!("session_id = ?{}", params.len()));
1201    }
1202
1203    push_observation_exists(&mut conditions, &mut params, None, &filter.observed);
1204    push_observation_exists(
1205        &mut conditions,
1206        &mut params,
1207        Some("selected"),
1208        &filter.selected,
1209    );
1210
1211    if let Some(proposal_id) = filter.payload_proposal_id {
1212        params.push(Box::new(proposal_id.to_string()));
1213        conditions.push(format!(
1214            "json_extract(payload, '$.proposal_id') = ?{}",
1215            params.len()
1216        ));
1217    }
1218
1219    let clause = format!(" WHERE {}", conditions.join(" AND "));
1220    Ok((clause, params))
1221}
1222
1223fn query_events_sql(where_clause: &str, limit_idx: usize, offset_idx: usize) -> String {
1224    format!(
1225        "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
1226                payload_schema_version, profile_state_version, duration_us, target_id, \
1227                session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution \
1228         FROM events{where_clause} ORDER BY created_at DESC, id DESC LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1229    )
1230}
1231
1232fn push_in_clause<I>(
1233    conditions: &mut Vec<String>,
1234    params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
1235    column: &'static str,
1236    values: I,
1237) where
1238    I: IntoIterator<Item = String>,
1239{
1240    let placeholders: Vec<String> = values
1241        .into_iter()
1242        .map(|value| {
1243            params.push(Box::new(value));
1244            format!("?{}", params.len())
1245        })
1246        .collect();
1247    if !placeholders.is_empty() {
1248        conditions.push(format!("{column} IN ({})", placeholders.join(",")));
1249    }
1250}
1251
1252fn push_observation_exists(
1253    conditions: &mut Vec<String>,
1254    params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
1255    role: Option<&'static str>,
1256    entity_ids: &[Uuid],
1257) {
1258    if entity_ids.is_empty() {
1259        return;
1260    }
1261    let placeholders: Vec<String> = entity_ids
1262        .iter()
1263        .map(|id| {
1264            params.push(Box::new(id.to_string()));
1265            format!("?{}", params.len())
1266        })
1267        .collect();
1268    let role_clause = role
1269        .map(|role| format!(" AND o.role = '{role}'"))
1270        .unwrap_or_default();
1271    conditions.push(format!(
1272        "EXISTS (SELECT 1 FROM event_observations o \
1273         WHERE o.event_id = events.id{role_clause} AND o.entity_id IN ({}))",
1274        placeholders.join(",")
1275    ));
1276}
1277
1278fn reject_missing_event_filter_schema(
1279    conn: &rusqlite::Connection,
1280    filter: &EventFilter,
1281) -> Result<(), rusqlite::Error> {
1282    if filter.target_id.is_some() && !has_column(conn, "events", "target_id")? {
1283        return Err(schema_absent("events.target_id"));
1284    }
1285    if filter.session_id.is_some() && !has_column(conn, "events", "session_id")? {
1286        return Err(schema_absent("events.session_id"));
1287    }
1288    if (!filter.observed.is_empty() || !filter.selected.is_empty())
1289        && !has_table(conn, "event_observations")?
1290    {
1291        return Err(schema_absent("event_observations"));
1292    }
1293    if filter.payload_proposal_id.is_some() && !has_column(conn, "events", "payload")? {
1294        return Err(schema_absent("events.payload"));
1295    }
1296    Ok(())
1297}
1298
1299fn has_table(conn: &rusqlite::Connection, table: &'static str) -> Result<bool, rusqlite::Error> {
1300    conn.query_row(
1301        "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
1302        [table],
1303        |row| row.get(0),
1304    )
1305}
1306
1307fn has_column(
1308    conn: &rusqlite::Connection,
1309    table: &'static str,
1310    column: &'static str,
1311) -> Result<bool, rusqlite::Error> {
1312    conn.query_row(
1313        "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
1314        rusqlite::params![table, column],
1315        |row| row.get(0),
1316    )
1317}
1318
1319fn schema_absent(name: &'static str) -> rusqlite::Error {
1320    rusqlite::Error::ToSqlConversionFailure(
1321        format!("event filter requires missing schema element {name}; run migrations").into(),
1322    )
1323}
1324
1325// =============================================================================
1326// EventStore implementation
1327// =============================================================================
1328
1329#[async_trait]
1330impl EventStore for SqlEventStore {
1331    async fn append_event(&self, event: Event) -> Result<(), StorageError> {
1332        // ADR-067 Component A: when the write queue is enabled, route
1333        // through the pool-wide WriterTask. DML-only closure — no BEGIN
1334        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
1335        // owns the transaction.
1336        //
1337        // `event_rows` is counted here at the store seam (on success, both
1338        // paths) so every request-owned append — proposal lifecycle,
1339        // curation, mutation events — is covered without per-call-site
1340        // instrumentation. The enclosing per-dispatch audit row is appended
1341        // only after the usage snapshot is frozen, so it never counts itself.
1342        if let Some(writer_task) = self.current_writer_task("append_event")? {
1343            let result = writer_task
1344                .send_bounded_at(
1345                    move |conn| {
1346                        insert_event_with_observations(conn, &event)
1347                            .map_err(|e| map_err(e, "append_event"))
1348                    },
1349                    "append_event",
1350                )
1351                .await;
1352            khive_storage::usage::account_event_write(result.as_ref().map(|()| 1));
1353            return result;
1354        }
1355
1356        let result = self
1357            .with_writer("append_event", move |conn| {
1358                insert_event_with_observations(conn, &event)
1359            })
1360            .await;
1361        #[cfg(test)]
1362        let result = self.after_fallback_write_for_test(result);
1363        khive_storage::usage::account_event_write(result.as_ref().map(|()| 1));
1364        result
1365    }
1366
1367    async fn append_events(&self, events: Vec<Event>) -> Result<BatchWriteSummary, StorageError> {
1368        let attempted = events.len() as u64;
1369
1370        // ADR-067 Component A: when the write queue is enabled, route
1371        // through the pool-wide WriterTask. DML-only closure preserving the
1372        // all-or-nothing semantics (first failed insert aborts the whole
1373        // batch) — the WriterTask's run loop owns the enclosing transaction
1374        // and issues the ROLLBACK on `Err`.
1375        if let Some(writer_task) = self.current_writer_task("append_events")? {
1376            let result = writer_task
1377                .send_bounded_at(
1378                    move |conn| {
1379                        batch_append_events_dml(conn, &events, attempted)
1380                            .map_err(|e| map_err(e, "append_events"))
1381                    },
1382                    "append_events",
1383                )
1384                .await;
1385            khive_storage::usage::account_event_write(
1386                result.as_ref().map(|summary| summary.affected),
1387            );
1388            return result;
1389        }
1390
1391        let result = self
1392            .with_writer("append_events", move |conn| {
1393                batch_append_events_dml(conn, &events, attempted)
1394            })
1395            .await;
1396        #[cfg(test)]
1397        let result = self.after_fallback_write_for_test(result);
1398        khive_storage::usage::account_event_write(result.as_ref().map(|summary| summary.affected));
1399        result
1400    }
1401
1402    async fn get_event(&self, id: Uuid) -> Result<Option<Event>, StorageError> {
1403        let namespace = self.namespace.clone();
1404        let id_str = id.to_string();
1405
1406        self.with_reader("get_event", move |conn| {
1407            let mut stmt = conn.prepare(
1408                "SELECT id, namespace, verb, substrate, actor, kind, outcome, payload, \
1409                        payload_schema_version, profile_state_version, duration_us, target_id, \
1410                        session_id, aggregate_kind, aggregate_id, created_at, op_index, ref_resolution \
1411                 FROM events WHERE namespace = ?1 AND id = ?2",
1412            )?;
1413            let mut rows = stmt.query(rusqlite::params![namespace, id_str])?;
1414            match rows.next()? {
1415                Some(row) => Ok(Some(read_event(row)?)),
1416                None => Ok(None),
1417            }
1418        })
1419        .await
1420    }
1421
1422    async fn query_events(
1423        &self,
1424        filter: EventFilter,
1425        page: PageRequest,
1426    ) -> Result<Page<Event>, StorageError> {
1427        super::validate_json_equality_paths(
1428            &filter.payload_equalities,
1429            StorageCapability::Events,
1430            "query_events",
1431        )?;
1432        let namespace = self.namespace.clone();
1433        let limit_i64 = i64::from(page.limit);
1434        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1435            capability: StorageCapability::Events,
1436            operation: "query_events".into(),
1437            message: format!(
1438                "PageRequest: offset must be <= i64::MAX, got {}",
1439                page.offset
1440            ),
1441        })?;
1442
1443        self.with_reader("query_events", move |conn| {
1444            // No `COUNT(*)` here, and `total` is therefore `None`. The count
1445            // carried no `LIMIT`, so it scanned the whole filtered set on every
1446            // paged read while the data query below fetches only
1447            // `offset + limit` rows — and on the merged event plane a single
1448            // read paid it twice, once per underlying store. `Page.total` is
1449            // `Option<u64>` precisely so a store may decline to compute it, and
1450            // the merged fold in `khive-runtime::events_split` propagates `None`
1451            // rather than inventing a number. `count_events` below remains for
1452            // callers that genuinely want a cardinality.
1453            let (where_clause, filter_params) = build_event_filter_sql(conn, &namespace, &filter)?;
1454
1455            let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = filter_params;
1456            all_params.push(Box::new(limit_i64));
1457            all_params.push(Box::new(offset_i64));
1458
1459            let limit_idx = all_params.len() - 1;
1460            let offset_idx = all_params.len();
1461
1462            let data_sql = query_events_sql(&where_clause, limit_idx, offset_idx);
1463
1464            let mut stmt = conn.prepare(&data_sql)?;
1465            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1466                all_params.iter().map(|p| p.as_ref()).collect();
1467            let rows = stmt.query_map(param_refs.as_slice(), read_event)?;
1468
1469            let mut items = Vec::new();
1470            for row in rows {
1471                items.push(row?);
1472            }
1473
1474            Ok(Page { items, total: None })
1475        })
1476        .await
1477    }
1478
1479    async fn query_event_page(
1480        &self,
1481        query: khive_storage::event::EventPageQuery,
1482    ) -> Result<khive_storage::event::EventPageWindow, StorageError> {
1483        cursor::query_event_page(self, query).await
1484    }
1485
1486    async fn count_events(&self, filter: EventFilter) -> Result<u64, StorageError> {
1487        super::validate_json_equality_paths(
1488            &filter.payload_equalities,
1489            StorageCapability::Events,
1490            "count_events",
1491        )?;
1492        let namespace = self.namespace.clone();
1493
1494        self.with_reader("count_events", move |conn| {
1495            let (where_clause, params) = build_event_filter_sql(conn, &namespace, &filter)?;
1496            let sql = format!("SELECT COUNT(*) FROM events{}", where_clause);
1497            let mut stmt = conn.prepare(&sql)?;
1498            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1499                params.iter().map(|p| p.as_ref()).collect();
1500            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1501            Ok(count as u64)
1502        })
1503        .await
1504    }
1505
1506    async fn count_events_grouped(
1507        &self,
1508        filter: EventFilter,
1509        group_by: EventGroupBy,
1510    ) -> Result<BTreeMap<String, u64>, StorageError> {
1511        super::validate_json_equality_paths(
1512            &filter.payload_equalities,
1513            StorageCapability::Events,
1514            "count_events_grouped",
1515        )?;
1516        let namespace = self.namespace.clone();
1517        self.with_reader("count_events_grouped", move |conn| {
1518            let (where_clause, params) = build_event_filter_sql(conn, &namespace, &filter)?;
1519            // The identifier comes only from the closed enum; every filter value
1520            // (including the namespace) is still bound by the shared builder.
1521            let column = group_by.column();
1522            let sql =
1523                format!("SELECT {column}, COUNT(*) FROM events{where_clause} GROUP BY {column}");
1524            let mut stmt = conn.prepare(&sql)?;
1525            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1526                params.iter().map(|p| p.as_ref()).collect();
1527            let rows = stmt.query_map(param_refs.as_slice(), |row| {
1528                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
1529            })?;
1530            rows.collect()
1531        })
1532        .await
1533    }
1534
1535    fn preflight_event(&self, event: &Event) -> Result<(), StorageError> {
1536        event_insert_statements(event)
1537            .map(|_| ())
1538            .map_err(|e| map_err(e, "preflight_event"))
1539    }
1540
1541    async fn append_events_idempotent(
1542        &self,
1543        events: Vec<Event>,
1544    ) -> Result<IdempotentEventBatchResult, StorageError> {
1545        if let Some(writer_task) = self.current_writer_task("append_events_idempotent")? {
1546            return writer_task
1547                .send_bounded_at(
1548                    move |conn| {
1549                        idempotent_batch_dml(conn, &events)
1550                            .map_err(|e| map_err(e, "append_events_idempotent"))
1551                    },
1552                    "append_events_idempotent",
1553                )
1554                .await;
1555        }
1556
1557        self.with_writer("append_events_idempotent", move |conn| {
1558            idempotent_batch_dml(conn, &events)
1559        })
1560        .await
1561    }
1562
1563    fn supports_idempotent_audit_batch(&self) -> bool {
1564        true
1565    }
1566}
1567
1568// =============================================================================
1569// DDL
1570// =============================================================================
1571
1572const EVENTS_DDL: &str = include_str!("../../sql/events-ddl.sql");
1573const OPERATION_ATTRIBUTION_COLUMNS: &str =
1574    include_str!("../../sql/036-events-operation-attribution.sql");
1575
1576pub(crate) fn ensure_events_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1577    conn.execute_batch(EVENTS_DDL)?;
1578    ensure_operation_attribution_columns(conn)
1579}
1580
1581/// Add `op_index` and `ref_resolution` to an `events` table created before
1582/// they existed. `CREATE TABLE IF NOT EXISTS` leaves such a table unchanged,
1583/// and a standalone events database has no migration chain to run V36, so
1584/// every insert naming the two columns would fail. V36 calls this too, which
1585/// keeps it valid on a table the store DDL has already upgraded. Both columns
1586/// are added in one transaction (the caller's, if one is open).
1587pub(crate) fn ensure_operation_attribution_columns(
1588    conn: &rusqlite::Connection,
1589) -> Result<(), rusqlite::Error> {
1590    match (
1591        has_column(conn, "events", "op_index")?,
1592        has_column(conn, "events", "ref_resolution")?,
1593    ) {
1594        (true, true) => Ok(()),
1595        (false, false) if conn.is_autocommit() => {
1596            let tx = conn.unchecked_transaction()?;
1597            tx.execute_batch(OPERATION_ATTRIBUTION_COLUMNS)?;
1598            tx.commit()
1599        }
1600        (false, false) => conn.execute_batch(OPERATION_ATTRIBUTION_COLUMNS),
1601        (op_index, ref_resolution) => Err(rusqlite::Error::ToSqlConversionFailure(
1602            format!(
1603                "events table has only one operation attribution column \
1604                 (op_index={op_index}, ref_resolution={ref_resolution}); refusing to guess"
1605            )
1606            .into(),
1607        )),
1608    }
1609}
1610
1611#[cfg(test)]
1612#[path = "event_tests.rs"]
1613mod tests;
1614
1615#[cfg(test)]
1616#[path = "event_busy_tests.rs"]
1617mod direct_busy_tests;
1618
1619#[cfg(test)]
1620#[path = "event_fallback_usage_tests.rs"]
1621mod fallback_usage_tests;
1622
1623#[cfg(test)]
1624#[path = "event_plan_tests.rs"]
1625mod plan_tests;