Skip to main content

lean_ctx/core/context_os/
context_bus.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Mutex};
4
5use chrono::{DateTime, Utc};
6use rusqlite::{Connection, params};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use tokio::sync::broadcast;
10
11use super::observation::{
12    LocalObservationPayload, LocalObservationSource, LocalObservationV1,
13    ObservationPersistenceError, decode_row, lock_observation, validate_identity,
14};
15use crate::core::events::LeanCtxEvent;
16use crate::core::ocla_bus::{OclaBusRecord, OclaEvent};
17
18const MAX_READ_CONNS: usize = 4;
19
20fn encode_target_agents(target_agents: Option<&[String]>) -> Option<String> {
21    target_agents.map(|targets| serde_json::to_string(targets).unwrap_or_else(|_| "[]".to_string()))
22}
23
24fn decode_target_agents(raw: Option<String>) -> Option<Vec<String>> {
25    match raw {
26        None => None,
27        Some(value) => match serde_json::from_str::<Vec<String>>(&value) {
28            Ok(targets) => Some(targets),
29            Err(error) => {
30                // A malformed directed-list must never turn into a broadcast.
31                tracing::warn!("invalid directed-agent list in context event: {error}");
32                Some(Vec::new())
33            }
34        },
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum ContextEventKindV1 {
41    ToolCallRecorded,
42    SessionMutated,
43    KnowledgeRemembered,
44    ArtifactStored,
45    GraphBuilt,
46    ProofAdded,
47    ProvenanceCheckpoint,
48    ProvenanceEdit,
49    HealthChanged,
50}
51
52impl ContextEventKindV1 {
53    pub fn as_str(&self) -> &'static str {
54        match self {
55            Self::ToolCallRecorded => "tool_call_recorded",
56            Self::SessionMutated => "session_mutated",
57            Self::KnowledgeRemembered => "knowledge_remembered",
58            Self::ArtifactStored => "artifact_stored",
59            Self::GraphBuilt => "graph_built",
60            Self::ProofAdded => "proof_added",
61            Self::ProvenanceCheckpoint => "provenance_checkpoint",
62            Self::ProvenanceEdit => "provenance_edit",
63            Self::HealthChanged => "health_changed",
64        }
65    }
66
67    pub fn parse(s: &str) -> Self {
68        match s.trim().to_lowercase().as_str() {
69            "tool_call_recorded" => Self::ToolCallRecorded,
70            "session_mutated" => Self::SessionMutated,
71            "knowledge_remembered" => Self::KnowledgeRemembered,
72            "artifact_stored" => Self::ArtifactStored,
73            "graph_built" => Self::GraphBuilt,
74            "proof_added" => Self::ProofAdded,
75            "provenance_checkpoint" => Self::ProvenanceCheckpoint,
76            "provenance_edit" => Self::ProvenanceEdit,
77            "health_changed" => Self::HealthChanged,
78            other => {
79                tracing::warn!(
80                    "unknown ContextEventKind '{other}', defaulting to ToolCallRecorded"
81                );
82                Self::ToolCallRecorded
83            }
84        }
85    }
86
87    /// Classifies the consistency requirement for this event kind.
88    ///
89    /// - `Local`: Agent-local, never shared (tool reads, cache hits).
90    /// - `Eventual`: Broadcast via bus, other agents see it "soon" (knowledge, artifacts).
91    /// - `Strong`: Critical decisions that require acknowledgment before proceeding.
92    pub fn consistency_level(&self) -> ConsistencyLevel {
93        match self {
94            Self::ToolCallRecorded | Self::GraphBuilt => ConsistencyLevel::Local,
95            Self::KnowledgeRemembered
96            | Self::ArtifactStored
97            | Self::ProvenanceEdit
98            | Self::HealthChanged => ConsistencyLevel::Eventual,
99            Self::SessionMutated | Self::ProofAdded | Self::ProvenanceCheckpoint => {
100                ConsistencyLevel::Strong
101            }
102        }
103    }
104}
105
106/// Consistency requirement for shared context events.
107/// Ordered from least to most strict for filtering comparisons.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum ConsistencyLevel {
111    /// Agent-local, authoritative: session task, local cache, current file set.
112    Local = 0,
113    /// Shared, eventually consistent: knowledge facts, gotchas, artifact refs.
114    Eventual = 1,
115    /// Shared, strongly consistent: workspace config, critical decisions.
116    Strong = 2,
117}
118
119impl ConsistencyLevel {
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            Self::Local => "local",
123            Self::Eventual => "eventual",
124            Self::Strong => "strong",
125        }
126    }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct ContextEventV1 {
132    pub id: i64,
133    pub workspace_id: String,
134    pub channel_id: String,
135    pub kind: String,
136    pub actor: Option<String>,
137    pub timestamp: DateTime<Utc>,
138    pub version: i64,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub parent_id: Option<i64>,
141    pub consistency_level: String,
142    pub payload: Value,
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub target_agents: Option<Vec<String>>,
145}
146
147impl ContextEventV1 {
148    pub fn consistency(&self) -> ConsistencyLevel {
149        ContextEventKindV1::parse(&self.kind).consistency_level()
150    }
151
152    pub fn is_visible_to_agent(&self, agent_id: &str) -> bool {
153        match &self.target_agents {
154            None => true,
155            Some(targets) => targets.iter().any(|t| t == agent_id),
156        }
157    }
158}
159
160/// Filter for selective event subscriptions.
161/// All fields are optional; `None` means "accept all". Directed events are
162/// included by default; callers without an agent identity can opt into the
163/// broadcast-only view with [`TopicFilter::broadcast_only`].
164#[derive(Debug, Clone)]
165pub struct TopicFilter {
166    pub kinds: Option<Vec<ContextEventKindV1>>,
167    pub actors: Option<Vec<String>>,
168    pub min_consistency: Option<ConsistencyLevel>,
169    pub agent_id: Option<String>,
170    pub include_directed: bool,
171}
172
173impl Default for TopicFilter {
174    fn default() -> Self {
175        Self {
176            kinds: None,
177            actors: None,
178            min_consistency: None,
179            agent_id: None,
180            include_directed: true,
181        }
182    }
183}
184
185impl TopicFilter {
186    /// Convenience constructor: filter by event kind strings.
187    pub fn kinds(kind_strs: &[&str]) -> Self {
188        Self {
189            kinds: Some(
190                kind_strs
191                    .iter()
192                    .map(|s| ContextEventKindV1::parse(s))
193                    .collect(),
194            ),
195            ..Self::default()
196        }
197    }
198
199    /// Filter that accepts broadcast events while excluding agent-directed
200    /// events for callers that have no authenticated agent identity.
201    pub fn broadcast_only() -> Self {
202        Self {
203            include_directed: false,
204            ..Self::default()
205        }
206    }
207
208    pub fn matches(&self, event: &ContextEventV1) -> bool {
209        if !self.include_directed && event.target_agents.is_some() {
210            return false;
211        }
212        if let Some(ref kinds) = self.kinds {
213            let parsed = ContextEventKindV1::parse(&event.kind);
214            if !kinds.contains(&parsed) {
215                return false;
216            }
217        }
218        if let Some(ref actors) = self.actors {
219            match &event.actor {
220                Some(actor) if actors.iter().any(|a| a == actor) => {}
221                Some(_) | None => return false,
222            }
223        }
224        if let Some(min) = self.min_consistency
225            && event.consistency() < min
226        {
227            return false;
228        }
229        if let Some(ref aid) = self.agent_id
230            && !event.is_visible_to_agent(aid)
231        {
232            return false;
233        }
234        true
235    }
236}
237
238fn event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ContextEventV1> {
239    let ts_str: String = row.get(5)?;
240    let ts = DateTime::parse_from_rfc3339(&ts_str)
241        .map_or_else(|_| Utc::now(), |d| d.with_timezone(&Utc));
242    let payload_str: String = row.get(6)?;
243    let payload: Value = serde_json::from_str(&payload_str).unwrap_or(Value::Null);
244    let kind_str: String = row.get(3)?;
245    let target_agents_json: Option<String> = row.get(9)?;
246    let cl = ContextEventKindV1::parse(&kind_str)
247        .consistency_level()
248        .as_str()
249        .to_string();
250    Ok(ContextEventV1 {
251        id: row.get(0)?,
252        workspace_id: row.get(1)?,
253        channel_id: row.get(2)?,
254        kind: kind_str,
255        actor: row.get::<_, Option<String>>(4)?,
256        timestamp: ts,
257        version: row.get::<_, i64>(7).unwrap_or(0),
258        parent_id: row.get::<_, Option<i64>>(8).ok().flatten(),
259        consistency_level: cl,
260        payload,
261        target_agents: decode_target_agents(target_agents_json),
262    })
263}
264
265#[derive(Clone)]
266pub struct ContextBus {
267    inner: Arc<Inner>,
268}
269
270const STREAM_CHANNEL_SIZE: usize = 256;
271const MAX_SUBSCRIBERS_PER_CHANNEL: usize = 64;
272/// Bound the write-side version cache. Each entry is re-derivable from the DB via
273/// `MAX(version)`, so when the map exceeds this (e.g. a client cycling workspace/channel
274/// ids) it is simply cleared — costing at most one extra `MAX()` query per active channel.
275const MAX_VERSION_CACHE_ENTRIES: usize = 4096;
276
277struct Inner {
278    write_conn: Mutex<Connection>,
279    read_pool: Mutex<Vec<Connection>>,
280    streams: Mutex<HashMap<String, broadcast::Sender<ContextEventV1>>>,
281    version_cache: Mutex<HashMap<String, i64>>,
282    db_path: PathBuf,
283}
284
285impl Inner {
286    fn open_read_conn(path: &PathBuf) -> Connection {
287        Self::try_open_read_conn(path).expect("open read context-os db")
288    }
289
290    fn try_open_read_conn(path: &PathBuf) -> Result<Connection, ObservationPersistenceError> {
291        let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
292        conn.busy_timeout(std::time::Duration::from_secs(5))?;
293        conn.execute_batch("PRAGMA query_only=ON;")?;
294        Ok(conn)
295    }
296
297    fn try_take_read_conn(&self) -> Result<Connection, ObservationPersistenceError> {
298        let cached = lock_observation(&self.read_pool)?.pop();
299        match cached {
300            Some(conn) => Ok(conn),
301            None => Self::try_open_read_conn(&self.db_path),
302        }
303    }
304
305    fn take_read_conn(&self) -> Connection {
306        self.read_pool
307            .lock()
308            .unwrap_or_else(std::sync::PoisonError::into_inner)
309            .pop()
310            .unwrap_or_else(|| Self::open_read_conn(&self.db_path))
311    }
312
313    fn return_read_conn(&self, conn: Connection) {
314        let mut pool = self
315            .read_pool
316            .lock()
317            .unwrap_or_else(std::sync::PoisonError::into_inner);
318        if pool.len() < MAX_READ_CONNS {
319            pool.push(conn);
320        }
321    }
322
323    fn stream_key(workspace_id: &str, channel_id: &str) -> String {
324        format!("{workspace_id}\0{channel_id}")
325    }
326
327    fn next_version(&self, workspace_id: &str, channel_id: &str) -> i64 {
328        let key = Self::stream_key(workspace_id, channel_id);
329
330        {
331            let mut cache = self
332                .version_cache
333                .lock()
334                .unwrap_or_else(std::sync::PoisonError::into_inner);
335            if let Some(v) = cache.get_mut(&key) {
336                *v += 1;
337                return *v;
338            }
339        }
340
341        let conn = self.take_read_conn();
342        let v: i64 = conn
343            .query_row(
344                "SELECT COALESCE(MAX(version), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
345                params![workspace_id, channel_id],
346                |row| row.get(0),
347            )
348            .unwrap_or(0);
349        self.return_read_conn(conn);
350
351        let mut cache = self
352            .version_cache
353            .lock()
354            .unwrap_or_else(std::sync::PoisonError::into_inner);
355        if cache.len() > MAX_VERSION_CACHE_ENTRIES {
356            // Re-derivable from the DB; drop the whole cache rather than grow unbounded.
357            cache.clear();
358        }
359        let entry = cache.entry(key).or_insert(v);
360        *entry = (*entry).max(v) + 1;
361        *entry
362    }
363}
364
365impl Default for ContextBus {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371impl ContextBus {
372    pub fn new() -> Self {
373        let path = default_db_path();
374        Self::open_at(path)
375    }
376
377    /// Open a ContextBus backed by the given SQLite path. Useful for tests
378    /// that need an isolated database to avoid contention on Windows CI.
379    pub fn open_at(path: PathBuf) -> Self {
380        Self::try_open_at(path).expect("open context-os db")
381    }
382
383    /// Open without panicking or silently substituting a different data directory.
384    pub fn try_open_at(path: PathBuf) -> Result<Self, ObservationPersistenceError> {
385        if let Some(parent) = path.parent() {
386            std::fs::create_dir_all(parent)?;
387        }
388        let conn = Connection::open(&path)?;
389        conn.busy_timeout(std::time::Duration::from_secs(5))?;
390        conn.execute_batch(
391            "PRAGMA journal_mode=WAL;
392             CREATE TABLE IF NOT EXISTS context_events (
393               id INTEGER PRIMARY KEY AUTOINCREMENT,
394               workspace_id TEXT NOT NULL,
395               channel_id TEXT NOT NULL,
396               kind TEXT NOT NULL,
397               actor TEXT,
398               timestamp TEXT NOT NULL,
399               payload_json TEXT NOT NULL,
400               version INTEGER NOT NULL DEFAULT 0,
401               parent_id INTEGER,
402               delivery_id TEXT,
403               target_agents_json TEXT
404             );
405             CREATE INDEX IF NOT EXISTS idx_context_events_stream
406               ON context_events(workspace_id, channel_id, id);
407             CREATE TABLE IF NOT EXISTS context_local_observations (
408               id INTEGER PRIMARY KEY AUTOINCREMENT,
409               schema_version INTEGER NOT NULL CHECK(schema_version = 1),
410               source_kind TEXT NOT NULL,
411               producer_instance TEXT NOT NULL,
412               source_id TEXT NOT NULL,
413               source_timestamp TEXT NOT NULL,
414               payload_json TEXT NOT NULL,
415               UNIQUE(source_kind, producer_instance, source_id)
416             );
417             CREATE INDEX IF NOT EXISTS idx_context_local_observations_source
418               ON context_local_observations(source_kind, id);",
419        )?;
420
421        let _ = conn.execute_batch(
422            "ALTER TABLE context_events ADD COLUMN version INTEGER NOT NULL DEFAULT 0;",
423        );
424        let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN parent_id INTEGER;");
425        let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN delivery_id TEXT;");
426        let _ =
427            conn.execute_batch("ALTER TABLE context_events ADD COLUMN target_agents_json TEXT;");
428        conn.execute_batch(
429            "CREATE UNIQUE INDEX IF NOT EXISTS idx_context_events_delivery
430               ON context_events(workspace_id, channel_id, delivery_id)
431               WHERE delivery_id IS NOT NULL;",
432        )?;
433
434        let _ = conn.execute_batch(
435            "CREATE VIRTUAL TABLE IF NOT EXISTS context_events_fts USING fts5(
436               payload_text,
437               content=context_events,
438               content_rowid=id
439             );",
440        );
441
442        let mut read_conns = Vec::with_capacity(MAX_READ_CONNS);
443        for _ in 0..MAX_READ_CONNS {
444            read_conns.push(Inner::try_open_read_conn(&path)?);
445        }
446
447        Ok(Self {
448            inner: Arc::new(Inner {
449                write_conn: Mutex::new(conn),
450                read_pool: Mutex::new(read_conns),
451                streams: Mutex::new(HashMap::new()),
452                version_cache: Mutex::new(HashMap::new()),
453                db_path: path,
454            }),
455        })
456    }
457
458    #[cfg(test)]
459    pub(crate) fn db_path(&self) -> &std::path::Path {
460        &self.inner.db_path
461    }
462
463    pub fn subscribe(
464        &self,
465        workspace_id: &str,
466        channel_id: &str,
467    ) -> Option<broadcast::Receiver<ContextEventV1>> {
468        let key = Inner::stream_key(workspace_id, channel_id);
469        let mut streams = self
470            .inner
471            .streams
472            .lock()
473            .unwrap_or_else(std::sync::PoisonError::into_inner);
474        // Reap senders left behind by departed clients (receiver_count() == 0). A removed
475        // key is transparently recreated below; the DB is the durability source so this
476        // loses no deliverable events. Bounds `streams` to ~live connection count even
477        // under client-cycled workspace/channel ids.
478        streams.retain(|_, tx| tx.receiver_count() > 0);
479        let tx = streams
480            .entry(key)
481            .or_insert_with(|| broadcast::channel(STREAM_CHANNEL_SIZE).0);
482        if tx.receiver_count() >= MAX_SUBSCRIBERS_PER_CHANNEL {
483            tracing::warn!(
484                "SSE subscriber cap ({MAX_SUBSCRIBERS_PER_CHANNEL}) reached for {workspace_id}/{channel_id} — rejecting"
485            );
486            return None;
487        }
488        Some(tx.subscribe())
489    }
490
491    /// Subscribe with a filter — only events matching the filter are delivered.
492    /// Returns `(Receiver, TopicFilter)` for use in filtered receive loops.
493    pub fn subscribe_filtered(
494        &self,
495        workspace_id: &str,
496        channel_id: &str,
497        filter: TopicFilter,
498    ) -> Option<FilteredSubscription> {
499        let rx = self.subscribe(workspace_id, channel_id)?;
500        Some(FilteredSubscription { rx, filter })
501    }
502
503    pub fn append(
504        &self,
505        workspace_id: &str,
506        channel_id: &str,
507        kind: &ContextEventKindV1,
508        actor: Option<&str>,
509        payload: Value,
510    ) -> Option<ContextEventV1> {
511        self.append_with_parent(workspace_id, channel_id, kind, actor, payload, None)
512    }
513
514    /// Persist an OCLA record in the ContextBus-owned private observation table.
515    /// The table is deliberately outside `context_events`, so normal replay,
516    /// search, lineage, and subscriptions cannot expose local observations.
517    pub fn append_ocla_observation(
518        &self,
519        producer_instance: &str,
520        record: &OclaBusRecord,
521    ) -> Result<i64, ObservationPersistenceError> {
522        let payload_json = serde_json::to_string(&record.event)?;
523        // JSON maps non-finite numbers to null; require a readable typed payload.
524        let _: OclaEvent = serde_json::from_str(&payload_json)?;
525        super::observation::admit_payload(&payload_json)?;
526        self.insert_local_observation(
527            LocalObservationSource::Ocla,
528            producer_instance,
529            record.id,
530            &record.timestamp_ms.to_string(),
531            &payload_json,
532        )
533    }
534
535    /// Persist a legacy event as a compatibility observation without appending
536    /// to the immutable historical JSONL journal.
537    pub fn append_legacy_observation(
538        &self,
539        producer_instance: &str,
540        event: &LeanCtxEvent,
541    ) -> Result<i64, ObservationPersistenceError> {
542        let payload_json = serde_json::to_string(event)?;
543        let _: LeanCtxEvent = serde_json::from_str(&payload_json)?;
544        super::observation::admit_payload(&payload_json)?;
545        self.insert_local_observation(
546            LocalObservationSource::Legacy,
547            producer_instance,
548            event.id,
549            &event.timestamp,
550            &payload_json,
551        )
552    }
553
554    fn insert_local_observation(
555        &self,
556        source: LocalObservationSource,
557        producer_instance: &str,
558        source_id: u64,
559        source_timestamp: &str,
560        payload_json: &str,
561    ) -> Result<i64, ObservationPersistenceError> {
562        use rusqlite::OptionalExtension;
563        validate_identity(producer_instance, source_id)?;
564        let mut conn = lock_observation(&self.inner.write_conn)?;
565        let transaction =
566            conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
567        let existing: Option<(i64, String, String)> = transaction
568            .query_row(
569                "SELECT id, source_timestamp, payload_json FROM context_local_observations
570             WHERE source_kind = ?1 AND producer_instance = ?2 AND source_id = ?3",
571                params![source.as_str(), producer_instance, source_id.to_string()],
572                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
573            )
574            .optional()?;
575        if let Some((cursor, timestamp, payload)) = existing {
576            if timestamp != source_timestamp || payload != payload_json {
577                return Err(ObservationPersistenceError::IdentityConflict);
578            }
579            transaction.commit()?;
580            return Ok(cursor);
581        }
582        transaction.execute(
583            "INSERT INTO context_local_observations
584             (schema_version, source_kind, producer_instance, source_id, source_timestamp, payload_json)
585             VALUES (1, ?1, ?2, ?3, ?4, ?5)",
586            params![
587                source.as_str(),
588                producer_instance,
589                source_id.to_string(),
590                source_timestamp,
591                payload_json
592            ],
593        )?;
594        let cursor_id = transaction.last_insert_rowid();
595        transaction.commit()?;
596        Ok(cursor_id)
597    }
598
599    /// Read private observations for regression and compatibility readers.
600    pub fn read_local_observations(
601        &self,
602        limit: usize,
603    ) -> Result<Vec<LocalObservationV1>, ObservationPersistenceError> {
604        let limit = limit.clamp(1, 10_000) as i64;
605        let conn = self.inner.try_take_read_conn()?;
606        let result = (|| {
607            let mut stmt = conn.prepare(
608                "SELECT id, source_kind, source_id, source_timestamp, payload_json,
609                        producer_instance, schema_version
610                 FROM context_local_observations ORDER BY id ASC LIMIT ?1",
611            )?;
612            let mut rows = stmt.query(params![limit])?;
613            let mut observations = Vec::new();
614            while let Some(row) = rows.next()? {
615                if let Some(observation) = decode_row(row)? {
616                    observations.push(observation);
617                }
618            }
619            Ok(observations)
620        })();
621        self.return_observation_read_conn(conn)?;
622        result
623    }
624
625    pub fn read_legacy_observations(
626        &self,
627        limit: usize,
628    ) -> Result<Vec<LeanCtxEvent>, ObservationPersistenceError> {
629        let limit = limit.clamp(1, 10_000) as i64;
630        let conn = self.inner.try_take_read_conn()?;
631        let result = (|| {
632            let mut stmt = conn.prepare(
633                "SELECT id, source_kind, source_id, source_timestamp, payload_json,
634                            producer_instance, schema_version FROM context_local_observations
635                 WHERE source_kind = 'legacy' ORDER BY id DESC LIMIT ?1",
636            )?;
637            let mut rows = stmt.query(params![limit])?;
638            let mut events = Vec::new();
639            while let Some(row) = rows.next()? {
640                let Some(observation) = decode_row(row)? else {
641                    continue;
642                };
643                let LocalObservationPayload::Legacy(event) = observation.payload else {
644                    return Err(ObservationPersistenceError::InvalidRow(
645                        "non-legacy observation in legacy projection".into(),
646                    ));
647                };
648                events.push(event);
649            }
650            events.reverse();
651            Ok(events)
652        })();
653        self.return_observation_read_conn(conn)?;
654        result
655    }
656
657    fn return_observation_read_conn(
658        &self,
659        conn: Connection,
660    ) -> Result<(), ObservationPersistenceError> {
661        let mut pool = lock_observation(&self.inner.read_pool)?;
662        if pool.len() < MAX_READ_CONNS {
663            pool.push(conn);
664        }
665        Ok(())
666    }
667
668    pub fn append_with_parent(
669        &self,
670        workspace_id: &str,
671        channel_id: &str,
672        kind: &ContextEventKindV1,
673        actor: Option<&str>,
674        payload: Value,
675        parent_id: Option<i64>,
676    ) -> Option<ContextEventV1> {
677        let ev = self.insert_event(
678            workspace_id,
679            channel_id,
680            kind,
681            actor,
682            payload,
683            parent_id,
684            None,
685        )?;
686        self.broadcast_event(&ev);
687        Some(ev)
688    }
689
690    /// Append an event directed at specific agents only.
691    /// Only subscribers whose `TopicFilter.agent_id` matches a target will see it.
692    pub fn append_directed(
693        &self,
694        workspace_id: &str,
695        channel_id: &str,
696        kind: &ContextEventKindV1,
697        actor: Option<&str>,
698        payload: Value,
699        target_agents: Vec<String>,
700    ) -> Option<ContextEventV1> {
701        let ev = self.insert_event(
702            workspace_id,
703            channel_id,
704            kind,
705            actor,
706            payload,
707            None,
708            Some(target_agents),
709        )?;
710        self.broadcast_event(&ev);
711        Some(ev)
712    }
713
714    fn insert_event(
715        &self,
716        workspace_id: &str,
717        channel_id: &str,
718        kind: &ContextEventKindV1,
719        actor: Option<&str>,
720        payload: Value,
721        parent_id: Option<i64>,
722        target_agents: Option<Vec<String>>,
723    ) -> Option<ContextEventV1> {
724        let ts = Utc::now();
725        let payload_json = payload.to_string();
726        let target_agents_json = encode_target_agents(target_agents.as_deref());
727
728        let (id, version) = {
729            let Ok(conn) = self.inner.write_conn.lock() else {
730                return None;
731            };
732            let version = self.inner.next_version(workspace_id, channel_id);
733
734            let result: Result<(i64, i64), rusqlite::Error> = conn
735                .execute_batch("BEGIN IMMEDIATE")
736                .and_then(|()| {
737                    conn.execute(
738                        "INSERT INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json)
739                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
740                        params![
741                            workspace_id,
742                            channel_id,
743                            kind.as_str(),
744                            actor.map(str::to_string),
745                            ts.to_rfc3339(),
746                            payload_json,
747                            version,
748                            parent_id,
749                            target_agents_json,
750                        ],
751                    )?;
752                    let rowid = conn.last_insert_rowid();
753                    if let Err(e) = conn.execute(
754                        "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
755                        params![rowid, payload_json],
756                    ) {
757                        tracing::warn!("FTS insert failed for event {rowid}: {e}");
758                    }
759                    conn.execute_batch("COMMIT")?;
760                    Ok((rowid, version))
761                });
762
763            match result {
764                Ok(pair) => pair,
765                Err(e) => {
766                    tracing::warn!("context bus append failed: {e}");
767                    let _ = conn.execute_batch("ROLLBACK");
768                    return None;
769                }
770            }
771        };
772
773        Some(ContextEventV1 {
774            id,
775            workspace_id: workspace_id.to_string(),
776            channel_id: channel_id.to_string(),
777            consistency_level: kind.consistency_level().as_str().to_string(),
778            kind: kind.as_str().to_string(),
779            actor: actor.map(str::to_string),
780            timestamp: ts,
781            version,
782            parent_id,
783            payload,
784            target_agents,
785        })
786    }
787
788    fn broadcast_event(&self, ev: &ContextEventV1) {
789        let key = Inner::stream_key(&ev.workspace_id, &ev.channel_id);
790        let tx = self
791            .inner
792            .streams
793            .lock()
794            .unwrap_or_else(std::sync::PoisonError::into_inner)
795            .get(&key)
796            .cloned();
797        if let Some(tx) = tx {
798            let _ = tx.send(ev.clone());
799        }
800    }
801
802    pub fn read(
803        &self,
804        workspace_id: &str,
805        channel_id: &str,
806        since: i64,
807        limit: usize,
808    ) -> Vec<ContextEventV1> {
809        let limit = limit.clamp(1, 1000) as i64;
810        let conn = self.inner.take_read_conn();
811        let result = (|| {
812            let mut stmt = conn.prepare(
813                "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
814                 FROM context_events
815                  WHERE workspace_id = ?1 AND channel_id = ?2 AND id > ?3
816                 ORDER BY id ASC
817                 LIMIT ?4",
818            ).ok()?;
819            let rows = stmt
820                .query_map(
821                    params![workspace_id, channel_id, since, limit],
822                    event_from_row,
823                )
824                .ok()?;
825            Some(rows.flatten().collect::<Vec<_>>())
826        })();
827        self.inner.return_read_conn(conn);
828        result.unwrap_or_default()
829    }
830
831    /// Atomically append at most one event for an authenticated delivery ID.
832    ///
833    /// `target_agents` scopes the event like [`Self::append_directed`]: when
834    /// set, only subscribers and readers identifying as one of the targets see
835    /// it, live and on replay.
836    pub fn append_delivery_once(
837        &self,
838        workspace_id: &str,
839        channel_id: &str,
840        kind: &ContextEventKindV1,
841        actor: Option<&str>,
842        payload: Value,
843        delivery_id: &str,
844        target_agents: Option<Vec<String>>,
845    ) -> Result<bool, String> {
846        let ts = Utc::now();
847        let payload_json = payload.to_string();
848        let target_agents_json = encode_target_agents(target_agents.as_deref());
849        let (id, version) = {
850            let conn = self
851                .inner
852                .write_conn
853                .lock()
854                .map_err(|_| "context bus write lock poisoned".to_string())?;
855            conn.execute_batch("BEGIN IMMEDIATE")
856                .map_err(|error| error.to_string())?;
857            let version = self.inner.next_version(workspace_id, channel_id);
858            let inserted = conn
859                .execute(
860                    "INSERT OR IGNORE INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, delivery_id, target_agents_json)
861                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)",
862                    params![workspace_id, channel_id, kind.as_str(), actor, ts.to_rfc3339(), payload_json, version, delivery_id, target_agents_json],
863                )
864                .map_err(|error| error.to_string());
865            let inserted = match inserted {
866                Ok(value) => value,
867                Err(error) => {
868                    let _ = conn.execute_batch("ROLLBACK");
869                    return Err(error);
870                }
871            };
872            if inserted == 0 {
873                conn.execute_batch("COMMIT")
874                    .map_err(|error| error.to_string())?;
875                return Ok(false);
876            }
877            let rowid = conn.last_insert_rowid();
878            if let Err(error) = conn.execute(
879                "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
880                params![rowid, payload_json],
881            ) {
882                tracing::warn!("FTS insert failed for event {rowid}: {error}");
883            }
884            conn.execute_batch("COMMIT")
885                .map_err(|error| error.to_string())?;
886            (rowid, version)
887        };
888        self.broadcast_event(&ContextEventV1 {
889            id,
890            workspace_id: workspace_id.to_string(),
891            channel_id: channel_id.to_string(),
892            consistency_level: kind.consistency_level().as_str().to_string(),
893            kind: kind.as_str().to_string(),
894            actor: actor.map(str::to_string),
895            timestamp: ts,
896            version,
897            parent_id: None,
898            payload,
899            target_agents,
900        });
901        Ok(true)
902    }
903
904    /// Query recent events of a specific kind (for conflict detection).
905    pub fn recent_by_kind(
906        &self,
907        workspace_id: &str,
908        channel_id: &str,
909        kind: &str,
910        limit: usize,
911    ) -> Vec<ContextEventV1> {
912        let limit = limit.clamp(1, 100) as i64;
913        let conn = self.inner.take_read_conn();
914        let result = (|| {
915            let mut stmt = conn.prepare(
916                 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
917                  FROM context_events
918                 WHERE workspace_id = ?1 AND channel_id = ?2 AND kind = ?3
919                 ORDER BY id DESC
920                 LIMIT ?4",
921            ).ok()?;
922            let rows = stmt
923                .query_map(
924                    params![workspace_id, channel_id, kind, limit],
925                    event_from_row,
926                )
927                .ok()?;
928            Some(rows.flatten().collect::<Vec<_>>())
929        })();
930        self.inner.return_read_conn(conn);
931        result.unwrap_or_default()
932    }
933
934    /// Full-text search over event payloads via FTS5.
935    pub fn search(
936        &self,
937        workspace_id: &str,
938        channel_id: Option<&str>,
939        query: &str,
940        limit: usize,
941    ) -> Vec<ContextEventV1> {
942        let limit = limit.clamp(1, 100) as i64;
943        let conn = self.inner.take_read_conn();
944        let result =
945            if let Some(ch) = channel_id {
946                (|| {
947                    let mut stmt = conn.prepare(
948                    "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
949                            e.payload_json, e.version, e.parent_id, e.target_agents_json
950                     FROM context_events e
951                     JOIN context_events_fts f ON e.id = f.rowid
952                     WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2 AND e.channel_id = ?3
953                     ORDER BY f.rank
954                     LIMIT ?4",
955                ).ok()?;
956                    let rows = stmt
957                        .query_map(params![query, workspace_id, ch, limit], event_from_row)
958                        .ok()?;
959                    Some(rows.flatten().collect::<Vec<_>>())
960                })()
961            } else {
962                (|| {
963                    let mut stmt = conn.prepare(
964                    "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
965                            e.payload_json, e.version, e.parent_id, e.target_agents_json
966                     FROM context_events e
967                     JOIN context_events_fts f ON e.id = f.rowid
968                     WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2
969                     ORDER BY f.rank
970                     LIMIT ?3",
971                ).ok()?;
972                    let rows = stmt
973                        .query_map(params![query, workspace_id, limit], event_from_row)
974                        .ok()?;
975                    Some(rows.flatten().collect::<Vec<_>>())
976                })()
977            };
978        self.inner.return_read_conn(conn);
979        result.unwrap_or_default()
980    }
981
982    /// Trace the causal lineage of an event by following parent_id chains.
983    /// Only returns events belonging to the given workspace (tenant isolation).
984    pub fn lineage(
985        &self,
986        event_id: i64,
987        workspace_id: &str,
988        max_depth: usize,
989    ) -> Vec<ContextEventV1> {
990        let max_depth = max_depth.clamp(1, 50);
991        let conn = self.inner.take_read_conn();
992        let mut chain = Vec::new();
993        let mut current_id = Some(event_id);
994
995        for _ in 0..max_depth {
996            let Some(id) = current_id else {
997                break;
998            };
999            let ev = conn.query_row(
1000                "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
1001                 FROM context_events WHERE id = ?1 AND workspace_id = ?2",
1002                params![id, workspace_id],
1003                event_from_row,
1004            );
1005            match ev {
1006                Ok(ev) => {
1007                    current_id = ev.parent_id;
1008                    chain.push(ev);
1009                }
1010                Err(_) => break,
1011            }
1012        }
1013        self.inner.return_read_conn(conn);
1014        chain
1015    }
1016
1017    /// Returns the highest event id for a workspace/channel pair, or 0 if none.
1018    pub fn latest_id(&self, workspace_id: &str, channel_id: &str) -> i64 {
1019        let conn = self.inner.take_read_conn();
1020        let result = conn
1021            .query_row(
1022                "SELECT COALESCE(MAX(id), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
1023                params![workspace_id, channel_id],
1024                |row| row.get(0),
1025            )
1026            .unwrap_or(0);
1027        self.inner.return_read_conn(conn);
1028        result
1029    }
1030}
1031
1032/// A subscription wrapper that applies a [`TopicFilter`] to received events.
1033pub struct FilteredSubscription {
1034    pub rx: broadcast::Receiver<ContextEventV1>,
1035    pub filter: TopicFilter,
1036}
1037
1038impl FilteredSubscription {
1039    /// Receive the next event that matches the filter.
1040    /// Skips non-matching events silently.
1041    pub async fn recv_filtered(&mut self) -> Result<ContextEventV1, broadcast::error::RecvError> {
1042        loop {
1043            let ev = self.rx.recv().await?;
1044            if self.filter.matches(&ev) {
1045                return Ok(ev);
1046            }
1047        }
1048    }
1049}
1050
1051fn default_db_path() -> PathBuf {
1052    let data = crate::core::data_dir::lean_ctx_data_dir().unwrap_or_else(|_| PathBuf::from("."));
1053    data.join("context-os").join("context-os.db")
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use tempfile::tempdir;
1060
1061    fn test_bus() -> (ContextBus, tempfile::TempDir) {
1062        let td = tempdir().expect("tempdir");
1063        let bus = ContextBus::open_at(td.path().join("test-context-os.db"));
1064        (bus, td)
1065    }
1066
1067    #[test]
1068    fn append_and_read_roundtrip() {
1069        let (bus, _td) = test_bus();
1070        let ev = bus
1071            .append(
1072                "ws",
1073                "ch",
1074                &ContextEventKindV1::ToolCallRecorded,
1075                Some("agent"),
1076                serde_json::json!({"tool":"ctx_read"}),
1077            )
1078            .expect("append");
1079        let got = bus.read("ws", "ch", ev.id - 1, 10);
1080        assert!(got.iter().any(|e| e.id == ev.id));
1081    }
1082
1083    #[test]
1084    fn authenticated_delivery_is_unique_across_bus_instances() {
1085        let td = tempdir().expect("tempdir");
1086        let path = td.path().join("delivery-context-os.db");
1087        let buses = [ContextBus::open_at(path.clone()), ContextBus::open_at(path)];
1088        let barrier = Arc::new(std::sync::Barrier::new(2));
1089        let handles: Vec<_> = buses
1090            .into_iter()
1091            .map(|bus| {
1092                let barrier = Arc::clone(&barrier);
1093                std::thread::spawn(move || {
1094                    barrier.wait();
1095                    bus.append_delivery_once(
1096                        "ws",
1097                        "a2a",
1098                        &ContextEventKindV1::SessionMutated,
1099                        Some("agent"),
1100                        serde_json::json!({"delivery_id":"delivery-1"}),
1101                        "delivery-1",
1102                        None,
1103                    )
1104                    .expect("delivery append")
1105                })
1106            })
1107            .collect();
1108        let inserted: Vec<bool> = handles
1109            .into_iter()
1110            .map(|handle| handle.join().expect("thread"))
1111            .collect();
1112        assert_eq!(inserted.iter().filter(|value| **value).count(), 1);
1113
1114        let bus = ContextBus::open_at(td.path().join("delivery-context-os.db"));
1115        assert_eq!(bus.read("ws", "a2a", 0, 10).len(), 1);
1116    }
1117
1118    #[test]
1119    fn directed_delivery_reaches_only_its_target_live_and_on_replay() {
1120        let td = tempdir().expect("tempdir");
1121        let path = td.path().join("directed-delivery-context-os.db");
1122        let bus = ContextBus::open_at(path.clone());
1123        let target_filter = || TopicFilter {
1124            agent_id: Some("agent-a".to_string()),
1125            ..TopicFilter::default()
1126        };
1127        let other_filter = || TopicFilter {
1128            agent_id: Some("agent-b".to_string()),
1129            ..TopicFilter::default()
1130        };
1131        let mut target_rx = bus.subscribe("ws", "a2a").expect("subscribe");
1132        let targets = vec!["agent-a".to_string()];
1133        assert!(
1134            bus.append_delivery_once(
1135                "ws",
1136                "a2a",
1137                &ContextEventKindV1::SessionMutated,
1138                Some("sender"),
1139                serde_json::json!({"delivery_id":"directed-1"}),
1140                "directed-1",
1141                Some(targets.clone()),
1142            )
1143            .expect("delivery append")
1144        );
1145        let live = target_rx.try_recv().expect("live delivery event");
1146        assert_eq!(live.target_agents, Some(targets.clone()));
1147        assert!(target_filter().matches(&live));
1148        assert!(!other_filter().matches(&live));
1149        assert!(!TopicFilter::broadcast_only().matches(&live));
1150        drop(bus);
1151
1152        let reopened = ContextBus::open_at(path);
1153        let replayed = reopened.read("ws", "a2a", 0, 10);
1154        assert_eq!(replayed.len(), 1);
1155        assert_eq!(replayed[0].target_agents, Some(targets));
1156        assert!(target_filter().matches(&replayed[0]));
1157        assert!(!other_filter().matches(&replayed[0]));
1158        assert!(!TopicFilter::broadcast_only().matches(&replayed[0]));
1159    }
1160
1161    #[test]
1162    fn multi_client_concurrent_appends_have_deterministic_ordering() {
1163        let (bus, _td) = test_bus();
1164        let bus = Arc::new(bus);
1165        let n_clients = 5;
1166        let n_events_per_client = 20;
1167        let ws = format!("ws-concurrent-{}", std::process::id());
1168        let ch = format!("ch-concurrent-{}", std::process::id());
1169
1170        let mut handles = vec![];
1171        for client_idx in 0..n_clients {
1172            let bus = Arc::clone(&bus);
1173            let ws = ws.clone();
1174            let ch = ch.clone();
1175            handles.push(std::thread::spawn(move || {
1176                let agent = format!("agent-{client_idx}");
1177                for event_idx in 0..n_events_per_client {
1178                    bus.append(
1179                        &ws,
1180                        &ch,
1181                        &ContextEventKindV1::ToolCallRecorded,
1182                        Some(&agent),
1183                        serde_json::json!({"client": client_idx, "seq": event_idx}),
1184                    );
1185                }
1186            }));
1187        }
1188
1189        for h in handles {
1190            h.join().unwrap();
1191        }
1192
1193        let all = bus.read(&ws, &ch, 0, 1000);
1194        assert_eq!(
1195            all.len(),
1196            n_clients * n_events_per_client,
1197            "all events should be persisted"
1198        );
1199
1200        let ids: Vec<i64> = all.iter().map(|e| e.id).collect();
1201        let mut sorted = ids.clone();
1202        sorted.sort_unstable();
1203        assert_eq!(ids, sorted, "events must be in strictly ascending ID order");
1204
1205        for win in ids.windows(2) {
1206            assert!(
1207                win[1] > win[0],
1208                "IDs must be strictly monotonic (no gaps from concurrent access)"
1209            );
1210        }
1211    }
1212
1213    #[test]
1214    fn workspace_channel_isolation() {
1215        let (bus, _td) = test_bus();
1216        let pid = std::process::id();
1217        let ws_a = format!("ws-iso-a-{pid}");
1218        let ws_b = format!("ws-iso-b-{pid}");
1219        let ws_c = format!("ws-iso-c-{pid}");
1220        let ch1 = format!("ch-iso-1-{pid}");
1221        let ch2 = format!("ch-iso-2-{pid}");
1222
1223        bus.append(
1224            &ws_a,
1225            &ch1,
1226            &ContextEventKindV1::SessionMutated,
1227            Some("agent-a"),
1228            serde_json::json!({"ws":"a","ch":"1"}),
1229        );
1230        bus.append(
1231            &ws_a,
1232            &ch2,
1233            &ContextEventKindV1::KnowledgeRemembered,
1234            Some("agent-a"),
1235            serde_json::json!({"ws":"a","ch":"2"}),
1236        );
1237        bus.append(
1238            &ws_b,
1239            &ch1,
1240            &ContextEventKindV1::ArtifactStored,
1241            Some("agent-b"),
1242            serde_json::json!({"ws":"b","ch":"1"}),
1243        );
1244
1245        let ws_a_ch_1 = bus.read(&ws_a, &ch1, 0, 100);
1246        assert_eq!(ws_a_ch_1.len(), 1);
1247        assert_eq!(ws_a_ch_1[0].kind, "session_mutated");
1248
1249        let ws_a_ch_2 = bus.read(&ws_a, &ch2, 0, 100);
1250        assert_eq!(ws_a_ch_2.len(), 1);
1251        assert_eq!(ws_a_ch_2[0].kind, "knowledge_remembered");
1252
1253        let ws_b_ch_1 = bus.read(&ws_b, &ch1, 0, 100);
1254        assert_eq!(ws_b_ch_1.len(), 1);
1255        assert_eq!(ws_b_ch_1[0].kind, "artifact_stored");
1256
1257        let ws_c_ch_1 = bus.read(&ws_c, &ch1, 0, 100);
1258        assert!(ws_c_ch_1.is_empty(), "non-existent workspace returns empty");
1259    }
1260
1261    #[test]
1262    fn replay_from_cursor_returns_only_newer_events() {
1263        let (bus, _td) = test_bus();
1264        let pid = std::process::id();
1265        let ws = &format!("ws-replay-{pid}");
1266        let ch = &format!("ch-replay-{pid}");
1267
1268        let ev1 = bus
1269            .append(
1270                ws,
1271                ch,
1272                &ContextEventKindV1::ToolCallRecorded,
1273                None,
1274                serde_json::json!({"seq":1}),
1275            )
1276            .unwrap();
1277        let ev2 = bus
1278            .append(
1279                ws,
1280                ch,
1281                &ContextEventKindV1::SessionMutated,
1282                None,
1283                serde_json::json!({"seq":2}),
1284            )
1285            .unwrap();
1286        let _ev3 = bus
1287            .append(
1288                ws,
1289                ch,
1290                &ContextEventKindV1::GraphBuilt,
1291                None,
1292                serde_json::json!({"seq":3}),
1293            )
1294            .unwrap();
1295
1296        let from_cursor = bus.read(ws, ch, ev2.id, 100);
1297        assert_eq!(from_cursor.len(), 1, "only events after cursor");
1298        assert_eq!(from_cursor[0].kind, "graph_built");
1299
1300        let from_first = bus.read(ws, ch, ev1.id, 100);
1301        assert_eq!(from_first.len(), 2, "events after first");
1302
1303        let from_zero = bus.read(ws, ch, 0, 100);
1304        assert_eq!(from_zero.len(), 3, "all events from zero");
1305    }
1306
1307    #[test]
1308    fn directed_visibility_survives_restart_and_filters_other_agents() {
1309        let td = tempdir().expect("tempdir");
1310        let path = td.path().join("directed-context-os.db");
1311        let bus = ContextBus::open_at(path.clone());
1312        let targets = vec!["agent-a".to_string()];
1313        let event = bus
1314            .append_directed(
1315                "ws",
1316                "ch",
1317                &ContextEventKindV1::SessionMutated,
1318                Some("lead"),
1319                serde_json::json!({"decision":"keep"}),
1320                targets.clone(),
1321            )
1322            .expect("directed event");
1323        assert_eq!(event.target_agents, Some(targets.clone()));
1324        drop(bus);
1325
1326        let reopened = ContextBus::open_at(path);
1327        let replayed = reopened.read("ws", "ch", 0, 10);
1328        assert_eq!(replayed.len(), 1);
1329        assert_eq!(replayed[0].target_agents, Some(targets));
1330        assert!(
1331            TopicFilter {
1332                agent_id: Some("agent-a".to_string()),
1333                ..TopicFilter::default()
1334            }
1335            .matches(&replayed[0])
1336        );
1337        assert!(
1338            !TopicFilter {
1339                agent_id: Some("agent-b".to_string()),
1340                ..TopicFilter::default()
1341            }
1342            .matches(&replayed[0])
1343        );
1344    }
1345
1346    #[test]
1347    fn legacy_schema_migrates_without_turning_events_into_directed_events() {
1348        let td = tempdir().expect("tempdir");
1349        let path = td.path().join("legacy-context-os.db");
1350        {
1351            let conn = Connection::open(&path).expect("legacy db");
1352            conn.execute_batch(
1353                "CREATE TABLE context_events (
1354                   id INTEGER PRIMARY KEY AUTOINCREMENT,
1355                   workspace_id TEXT NOT NULL,
1356                   channel_id TEXT NOT NULL,
1357                   kind TEXT NOT NULL,
1358                   actor TEXT,
1359                   timestamp TEXT NOT NULL,
1360                   payload_json TEXT NOT NULL,
1361                   version INTEGER NOT NULL DEFAULT 0,
1362                   parent_id INTEGER,
1363                   delivery_id TEXT
1364                 );
1365                 INSERT INTO context_events
1366                   (workspace_id, channel_id, kind, timestamp, payload_json)
1367                 VALUES ('ws', 'ch', 'knowledge_remembered',
1368                         '2026-01-01T00:00:00Z', '{}');",
1369            )
1370            .expect("legacy schema");
1371        }
1372
1373        let bus = ContextBus::open_at(path);
1374        let replayed = bus.read("ws", "ch", 0, 10);
1375        assert_eq!(replayed.len(), 1);
1376        assert_eq!(replayed[0].target_agents, None);
1377    }
1378
1379    #[test]
1380    fn broadcast_subscriber_receives_events() {
1381        let (bus, _td) = test_bus();
1382        let mut rx = bus.subscribe("ws", "ch").expect("subscribe should succeed");
1383
1384        let ev = bus
1385            .append(
1386                "ws",
1387                "ch",
1388                &ContextEventKindV1::ProofAdded,
1389                Some("verifier"),
1390                serde_json::json!({"proof":"hash"}),
1391            )
1392            .unwrap();
1393
1394        let received = rx.try_recv().expect("subscriber should receive event");
1395        assert_eq!(received.id, ev.id);
1396        assert_eq!(received.kind, "proof_added");
1397        assert_eq!(received.actor.as_deref(), Some("verifier"));
1398    }
1399
1400    #[test]
1401    fn event_kind_parser_roundtrips_all_v1_kinds() {
1402        for kind in [
1403            ContextEventKindV1::ToolCallRecorded,
1404            ContextEventKindV1::SessionMutated,
1405            ContextEventKindV1::KnowledgeRemembered,
1406            ContextEventKindV1::ArtifactStored,
1407            ContextEventKindV1::GraphBuilt,
1408            ContextEventKindV1::ProofAdded,
1409            ContextEventKindV1::ProvenanceCheckpoint,
1410            ContextEventKindV1::ProvenanceEdit,
1411            ContextEventKindV1::HealthChanged,
1412        ] {
1413            assert_eq!(ContextEventKindV1::parse(kind.as_str()), kind);
1414        }
1415    }
1416}
1417// SPDX-License-Identifier: Apache-2.0