Skip to main content

meerkat_workgraph/
store.rs

1use std::collections::BTreeMap;
2#[cfg(not(target_arch = "wasm32"))]
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5#[cfg(not(target_arch = "wasm32"))]
6use std::time::Duration;
7
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10#[cfg(not(target_arch = "wasm32"))]
11use rusqlite::{
12    Connection, Error, ErrorCode, OptionalExtension, Transaction, TransactionBehavior, params,
13};
14
15use crate::WorkGraphError;
16use crate::types::{
17    AttentionListRequest, AttentionPruneRequest, WorkAttentionBinding, WorkAttentionBindingId,
18    WorkAttentionStatus, WorkEdge, WorkGraphEvent, WorkGraphEventKind, WorkItem, WorkItemFilter,
19    WorkItemId, WorkNamespace,
20};
21use crate::{WorkAttentionMachine, WorkGraphMachine};
22
23#[cfg(not(target_arch = "wasm32"))]
24const SQLITE_BUSY_TIMEOUT_MS: u64 = 5000;
25
26#[cfg(target_arch = "wasm32")]
27use crate::tokio::sync::RwLock;
28#[cfg(not(target_arch = "wasm32"))]
29use tokio::sync::RwLock;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum WorkGraphStoreKind {
33    Disabled,
34    Memory,
35    Sqlite,
36    Custom,
37}
38
39impl WorkGraphStoreKind {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Disabled => "disabled",
43            Self::Memory => "memory",
44            Self::Sqlite => "sqlite",
45            Self::Custom => "custom",
46        }
47    }
48}
49
50impl std::fmt::Display for WorkGraphStoreKind {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(self.as_str())
53    }
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct WorkGraphEventFilter {
59    pub realm_id: Option<String>,
60    pub namespace: Option<WorkNamespace>,
61    #[serde(default)]
62    pub all_namespaces: bool,
63    pub after_seq: Option<i64>,
64    pub limit: Option<usize>,
65}
66
67#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
68#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
69pub trait WorkGraphStore: Send + Sync {
70    fn kind(&self) -> WorkGraphStoreKind;
71
72    async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError>;
73
74    async fn insert_item(
75        &self,
76        item: WorkItem,
77        event: WorkGraphEvent,
78    ) -> Result<WorkItem, WorkGraphError>;
79
80    async fn update_item_cas(
81        &self,
82        item: WorkItem,
83        expected_previous_revision: u64,
84        event: WorkGraphEvent,
85    ) -> Result<WorkItem, WorkGraphError>;
86
87    async fn update_item_and_attention_cas(
88        &self,
89        item: WorkItem,
90        expected_previous_revision: u64,
91        item_event: WorkGraphEvent,
92        attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
93    ) -> Result<WorkItem, WorkGraphError>;
94
95    async fn get_item(
96        &self,
97        realm_id: &str,
98        namespace: &WorkNamespace,
99        id: &WorkItemId,
100    ) -> Result<Option<WorkItem>, WorkGraphError>;
101
102    async fn list_items(&self, filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError>;
103
104    async fn insert_goal(
105        &self,
106        _item: WorkItem,
107        _item_event: WorkGraphEvent,
108        _attention: WorkAttentionBinding,
109        _attention_event: WorkGraphEvent,
110    ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
111        Err(unsupported(self.kind()))
112    }
113
114    async fn update_attention_cas(
115        &self,
116        _attention: WorkAttentionBinding,
117        _expected_previous_revision: u64,
118        _event: WorkGraphEvent,
119    ) -> Result<WorkAttentionBinding, WorkGraphError> {
120        Err(unsupported(self.kind()))
121    }
122
123    async fn reassign_attention_cas(
124        &self,
125        _previous: WorkAttentionBinding,
126        _expected_previous_revision: u64,
127        _previous_event: WorkGraphEvent,
128        _replacement: WorkAttentionBinding,
129        _replacement_event: WorkGraphEvent,
130    ) -> Result<(WorkAttentionBinding, WorkAttentionBinding), WorkGraphError> {
131        Err(unsupported(self.kind()))
132    }
133
134    async fn get_attention(
135        &self,
136        _realm_id: &str,
137        _namespace: &WorkNamespace,
138        _binding_id: &WorkAttentionBindingId,
139    ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
140        Err(unsupported(self.kind()))
141    }
142
143    async fn list_attention(
144        &self,
145        _filter: AttentionListRequest,
146    ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
147        Err(unsupported(self.kind()))
148    }
149
150    /// Return at most `limit` attention rows. Backends should push this bound
151    /// into iteration/query ownership; the default is compatibility-only for
152    /// custom stores.
153    async fn list_attention_bounded(
154        &self,
155        filter: AttentionListRequest,
156        limit: usize,
157    ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
158        let mut bindings = self.list_attention(filter).await?;
159        bindings.truncate(limit);
160        Ok(bindings)
161    }
162
163    /// Delete TERMINAL (superseded/stopped) attention binding rows in scope.
164    /// The event stream keeps the audit history; binding rows otherwise grow
165    /// monotonically with reassignment churn. Returns the pruned row count.
166    async fn prune_terminal_attention(
167        &self,
168        _filter: AttentionPruneRequest,
169    ) -> Result<u64, WorkGraphError> {
170        Err(unsupported(self.kind()))
171    }
172
173    async fn insert_edge(
174        &self,
175        edge: WorkEdge,
176        event: WorkGraphEvent,
177    ) -> Result<WorkEdge, WorkGraphError>;
178
179    async fn insert_edge_validated(
180        &self,
181        _edge: WorkEdge,
182        _event: WorkGraphEvent,
183    ) -> Result<WorkEdge, WorkGraphError> {
184        Err(unsupported(self.kind()))
185    }
186
187    async fn list_edges(
188        &self,
189        realm_id: &str,
190        namespace: &WorkNamespace,
191    ) -> Result<Vec<WorkEdge>, WorkGraphError>;
192
193    /// Return at most `limit` edges in one namespace.
194    async fn list_edges_bounded(
195        &self,
196        realm_id: &str,
197        namespace: &WorkNamespace,
198        limit: usize,
199    ) -> Result<Vec<WorkEdge>, WorkGraphError> {
200        let mut edges = self.list_edges(realm_id, namespace).await?;
201        edges.truncate(limit);
202        Ok(edges)
203    }
204
205    async fn list_events(
206        &self,
207        filter: WorkGraphEventFilter,
208    ) -> Result<Vec<WorkGraphEvent>, WorkGraphError>;
209
210    /// Highest sequence matching a scope without retaining the event history.
211    async fn latest_event_seq(
212        &self,
213        filter: WorkGraphEventFilter,
214    ) -> Result<Option<i64>, WorkGraphError> {
215        Ok(self
216            .list_events(filter)
217            .await?
218            .into_iter()
219            .filter_map(|event| event.seq)
220            .max())
221    }
222}
223
224#[derive(Default)]
225pub struct DisabledWorkGraphStore;
226
227#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
228#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
229impl WorkGraphStore for DisabledWorkGraphStore {
230    fn kind(&self) -> WorkGraphStoreKind {
231        WorkGraphStoreKind::Disabled
232    }
233
234    async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError> {
235        Err(unsupported(self.kind()))
236    }
237
238    async fn insert_item(
239        &self,
240        _item: WorkItem,
241        _event: WorkGraphEvent,
242    ) -> Result<WorkItem, WorkGraphError> {
243        Err(unsupported(self.kind()))
244    }
245
246    async fn update_item_cas(
247        &self,
248        _item: WorkItem,
249        _expected_previous_revision: u64,
250        _event: WorkGraphEvent,
251    ) -> Result<WorkItem, WorkGraphError> {
252        Err(unsupported(self.kind()))
253    }
254
255    async fn update_item_and_attention_cas(
256        &self,
257        _item: WorkItem,
258        _expected_previous_revision: u64,
259        _item_event: WorkGraphEvent,
260        _attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
261    ) -> Result<WorkItem, WorkGraphError> {
262        Err(unsupported(self.kind()))
263    }
264
265    async fn get_item(
266        &self,
267        _realm_id: &str,
268        _namespace: &WorkNamespace,
269        _id: &WorkItemId,
270    ) -> Result<Option<WorkItem>, WorkGraphError> {
271        Err(unsupported(self.kind()))
272    }
273
274    async fn list_items(&self, _filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError> {
275        Err(unsupported(self.kind()))
276    }
277
278    async fn insert_goal(
279        &self,
280        _item: WorkItem,
281        _item_event: WorkGraphEvent,
282        _attention: WorkAttentionBinding,
283        _attention_event: WorkGraphEvent,
284    ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
285        Err(unsupported(self.kind()))
286    }
287
288    async fn update_attention_cas(
289        &self,
290        _attention: WorkAttentionBinding,
291        _expected_previous_revision: u64,
292        _event: WorkGraphEvent,
293    ) -> Result<WorkAttentionBinding, WorkGraphError> {
294        Err(unsupported(self.kind()))
295    }
296
297    async fn get_attention(
298        &self,
299        _realm_id: &str,
300        _namespace: &WorkNamespace,
301        _binding_id: &WorkAttentionBindingId,
302    ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
303        Err(unsupported(self.kind()))
304    }
305
306    async fn list_attention(
307        &self,
308        _filter: AttentionListRequest,
309    ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
310        Err(unsupported(self.kind()))
311    }
312
313    async fn insert_edge(
314        &self,
315        _edge: WorkEdge,
316        _event: WorkGraphEvent,
317    ) -> Result<WorkEdge, WorkGraphError> {
318        Err(unsupported(self.kind()))
319    }
320
321    async fn insert_edge_validated(
322        &self,
323        _edge: WorkEdge,
324        _event: WorkGraphEvent,
325    ) -> Result<WorkEdge, WorkGraphError> {
326        Err(unsupported(self.kind()))
327    }
328
329    async fn list_edges(
330        &self,
331        _realm_id: &str,
332        _namespace: &WorkNamespace,
333    ) -> Result<Vec<WorkEdge>, WorkGraphError> {
334        Err(unsupported(self.kind()))
335    }
336
337    async fn list_events(
338        &self,
339        _filter: WorkGraphEventFilter,
340    ) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
341        Err(unsupported(self.kind()))
342    }
343}
344
345fn unsupported(kind: WorkGraphStoreKind) -> WorkGraphError {
346    WorkGraphError::UnsupportedBackend(kind.to_string())
347}
348
349#[derive(Default)]
350pub struct MemoryWorkGraphStore {
351    inner: Arc<RwLock<MemoryWorkGraphState>>,
352}
353
354#[derive(Default)]
355struct MemoryWorkGraphState {
356    items: BTreeMap<(String, WorkNamespace, WorkItemId), WorkItem>,
357    attention: BTreeMap<(String, WorkNamespace, WorkAttentionBindingId), WorkAttentionBinding>,
358    edges: Vec<WorkEdge>,
359    events: Vec<WorkGraphEvent>,
360    next_event_seq: i64,
361}
362
363impl MemoryWorkGraphStore {
364    pub fn new() -> Self {
365        Self::default()
366    }
367}
368
369#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
370#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
371impl WorkGraphStore for MemoryWorkGraphStore {
372    fn kind(&self) -> WorkGraphStoreKind {
373        WorkGraphStoreKind::Memory
374    }
375
376    async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError> {
377        Ok(Utc::now())
378    }
379
380    async fn insert_item(
381        &self,
382        item: WorkItem,
383        event: WorkGraphEvent,
384    ) -> Result<WorkItem, WorkGraphError> {
385        WorkGraphMachine::validate_item_projection(&item)?;
386        let mut guard = self.inner.write().await;
387        let key = item_key(&item.realm_id, &item.namespace, &item.id);
388        if guard.items.contains_key(&key) {
389            return Err(WorkGraphError::Conflict(format!(
390                "work item {} already exists",
391                item.id
392            )));
393        }
394        guard.items.insert(key, item.clone());
395        guard.append_event(event);
396        Ok(item)
397    }
398
399    async fn update_item_cas(
400        &self,
401        item: WorkItem,
402        expected_previous_revision: u64,
403        event: WorkGraphEvent,
404    ) -> Result<WorkItem, WorkGraphError> {
405        WorkGraphMachine::validate_item_projection(&item)?;
406        let mut guard = self.inner.write().await;
407        let key = item_key(&item.realm_id, &item.namespace, &item.id);
408        let Some(current) = guard.items.get(&key) else {
409            return Err(WorkGraphError::not_found(
410                item.realm_id.clone(),
411                item.namespace.clone(),
412                item.id.clone(),
413            ));
414        };
415        if current.revision != expected_previous_revision {
416            return Err(WorkGraphError::StaleRevision {
417                id: item.id.clone(),
418                expected: expected_previous_revision,
419                actual: current.revision,
420            });
421        }
422        guard.items.insert(key, item.clone());
423        guard.append_event(event);
424        Ok(item)
425    }
426
427    async fn get_item(
428        &self,
429        realm_id: &str,
430        namespace: &WorkNamespace,
431        id: &WorkItemId,
432    ) -> Result<Option<WorkItem>, WorkGraphError> {
433        let guard = self.inner.read().await;
434        Ok(guard.items.get(&item_key(realm_id, namespace, id)).cloned())
435    }
436
437    async fn list_items(&self, filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError> {
438        let guard = self.inner.read().await;
439        let compare = |left: &WorkItem, right: &WorkItem| {
440            left.updated_at
441                .cmp(&right.updated_at)
442                .then_with(|| left.id.cmp(&right.id))
443        };
444        if let Some(limit) = filter.limit {
445            let mut items = Vec::with_capacity(limit.min(1024));
446            for item in guard
447                .items
448                .values()
449                .filter(|item| item_matches_filter(item, &filter))
450            {
451                let index = items
452                    .binary_search_by(|existing| compare(existing, item))
453                    .unwrap_or_else(|index| index);
454                if index < limit {
455                    items.insert(index, item.clone());
456                    if items.len() > limit {
457                        items.pop();
458                    }
459                }
460            }
461            return Ok(items);
462        }
463        let mut items = guard
464            .items
465            .values()
466            .filter(|item| item_matches_filter(item, &filter))
467            .cloned()
468            .collect::<Vec<_>>();
469        items.sort_by(compare);
470        Ok(items)
471    }
472
473    async fn insert_goal(
474        &self,
475        item: WorkItem,
476        item_event: WorkGraphEvent,
477        attention: WorkAttentionBinding,
478        attention_event: WorkGraphEvent,
479    ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
480        WorkGraphMachine::validate_item_projection(&item)?;
481        let mut guard = self.inner.write().await;
482        let item_key = item_key(&item.realm_id, &item.namespace, &item.id);
483        if guard.items.contains_key(&item_key) {
484            return Err(WorkGraphError::Conflict(format!(
485                "work item {} already exists",
486                item.id
487            )));
488        }
489        let attention_key = attention_key(
490            &attention.work_ref.realm_id,
491            &attention.work_ref.namespace,
492            &attention.binding_id,
493        );
494        if guard.attention.contains_key(&attention_key) {
495            return Err(WorkGraphError::Conflict(format!(
496                "work attention binding {} already exists",
497                attention.binding_id
498            )));
499        }
500        if let Some(occupant) = active_target_occupant_in(guard.attention.values(), &attention) {
501            return Err(active_target_conflict(&attention, &occupant));
502        }
503        guard.items.insert(item_key, item.clone());
504        guard.attention.insert(attention_key, attention.clone());
505        guard.append_event(item_event);
506        guard.append_event(attention_event);
507        Ok((item, attention))
508    }
509
510    async fn update_attention_cas(
511        &self,
512        attention: WorkAttentionBinding,
513        expected_previous_revision: u64,
514        event: WorkGraphEvent,
515    ) -> Result<WorkAttentionBinding, WorkGraphError> {
516        let mut guard = self.inner.write().await;
517        let key = attention_key(
518            &attention.work_ref.realm_id,
519            &attention.work_ref.namespace,
520            &attention.binding_id,
521        );
522        let Some(current) = guard.attention.get(&key) else {
523            return Err(WorkGraphError::not_found(
524                attention.work_ref.realm_id.clone(),
525                attention.work_ref.namespace.clone(),
526                attention.work_ref.item_id.clone(),
527            ));
528        };
529        if current.machine_state.revision != expected_previous_revision {
530            return Err(WorkGraphError::StaleRevision {
531                id: attention.work_ref.item_id.clone(),
532                expected: expected_previous_revision,
533                actual: current.machine_state.revision,
534            });
535        }
536        if let Some(occupant) = active_target_occupant_in(guard.attention.values(), &attention) {
537            return Err(active_target_conflict(&attention, &occupant));
538        }
539        guard.attention.insert(key, attention.clone());
540        guard.append_event(event);
541        Ok(attention)
542    }
543
544    async fn reassign_attention_cas(
545        &self,
546        previous: WorkAttentionBinding,
547        expected_previous_revision: u64,
548        previous_event: WorkGraphEvent,
549        replacement: WorkAttentionBinding,
550        replacement_event: WorkGraphEvent,
551    ) -> Result<(WorkAttentionBinding, WorkAttentionBinding), WorkGraphError> {
552        let mut guard = self.inner.write().await;
553        let previous_key = attention_key(
554            &previous.work_ref.realm_id,
555            &previous.work_ref.namespace,
556            &previous.binding_id,
557        );
558        let Some(current) = guard.attention.get(&previous_key) else {
559            return Err(WorkGraphError::attention_not_found(
560                previous.work_ref.realm_id.clone(),
561                previous.work_ref.namespace.clone(),
562                previous.binding_id.clone(),
563            ));
564        };
565        if current.machine_state.revision != expected_previous_revision {
566            return Err(WorkGraphError::StaleRevision {
567                id: previous.work_ref.item_id.clone(),
568                expected: expected_previous_revision,
569                actual: current.machine_state.revision,
570            });
571        }
572        let replacement_key = attention_key(
573            &replacement.work_ref.realm_id,
574            &replacement.work_ref.namespace,
575            &replacement.binding_id,
576        );
577        if guard.attention.contains_key(&replacement_key) {
578            return Err(WorkGraphError::Conflict(format!(
579                "work attention binding {} already exists",
580                replacement.binding_id
581            )));
582        }
583        // Occupancy over the post-reassign state: `previous` is being
584        // superseded in this same mutation, so it is excluded from the probe.
585        if let Some(occupant) = active_target_occupant_in(
586            guard
587                .attention
588                .values()
589                .filter(|binding| binding.binding_id != previous.binding_id),
590            &replacement,
591        ) {
592            return Err(active_target_conflict(&replacement, &occupant));
593        }
594        guard.attention.insert(previous_key, previous.clone());
595        guard.attention.insert(replacement_key, replacement.clone());
596        guard.append_event(previous_event);
597        guard.append_event(replacement_event);
598        Ok((previous, replacement))
599    }
600
601    async fn update_item_and_attention_cas(
602        &self,
603        item: WorkItem,
604        expected_previous_revision: u64,
605        item_event: WorkGraphEvent,
606        attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
607    ) -> Result<WorkItem, WorkGraphError> {
608        WorkGraphMachine::validate_item_projection(&item)?;
609        let mut guard = self.inner.write().await;
610        let key = item_key(&item.realm_id, &item.namespace, &item.id);
611        let Some(current) = guard.items.get(&key) else {
612            return Err(WorkGraphError::not_found(
613                item.realm_id.clone(),
614                item.namespace.clone(),
615                item.id.clone(),
616            ));
617        };
618        if current.revision != expected_previous_revision {
619            return Err(WorkGraphError::StaleRevision {
620                id: item.id.clone(),
621                expected: expected_previous_revision,
622                actual: current.revision,
623            });
624        }
625        for (attention, expected_revision, _) in &attention_updates {
626            let key = attention_key(
627                &attention.work_ref.realm_id,
628                &attention.work_ref.namespace,
629                &attention.binding_id,
630            );
631            let Some(current) = guard.attention.get(&key) else {
632                return Err(WorkGraphError::not_found(
633                    attention.work_ref.realm_id.clone(),
634                    attention.work_ref.namespace.clone(),
635                    attention.work_ref.item_id.clone(),
636                ));
637            };
638            if current.machine_state.revision != *expected_revision {
639                return Err(WorkGraphError::StaleRevision {
640                    id: attention.work_ref.item_id.clone(),
641                    expected: *expected_revision,
642                    actual: current.machine_state.revision,
643                });
644            }
645        }
646        // Occupancy over the post-update state: exclude every binding this
647        // batch rewrites, then judge each Active-status update against the
648        // survivors plus its already-applied batch predecessors.
649        let batch_ids: Vec<WorkAttentionBindingId> = attention_updates
650            .iter()
651            .map(|(attention, _, _)| attention.binding_id.clone())
652            .collect();
653        for (index, (attention, _, _)) in attention_updates.iter().enumerate() {
654            let occupant = active_target_occupant_in(
655                guard
656                    .attention
657                    .values()
658                    .filter(|binding| !batch_ids.contains(&binding.binding_id))
659                    .chain(
660                        attention_updates[..index]
661                            .iter()
662                            .map(|(applied, _, _)| applied),
663                    ),
664                attention,
665            );
666            if let Some(occupant) = occupant {
667                return Err(active_target_conflict(attention, &occupant));
668            }
669        }
670        guard.items.insert(key, item.clone());
671        guard.append_event(item_event);
672        for (attention, _, event) in attention_updates {
673            let key = attention_key(
674                &attention.work_ref.realm_id,
675                &attention.work_ref.namespace,
676                &attention.binding_id,
677            );
678            guard.attention.insert(key, attention);
679            guard.append_event(event);
680        }
681        Ok(item)
682    }
683
684    async fn get_attention(
685        &self,
686        realm_id: &str,
687        namespace: &WorkNamespace,
688        binding_id: &WorkAttentionBindingId,
689    ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
690        let guard = self.inner.read().await;
691        Ok(guard
692            .attention
693            .get(&attention_key(realm_id, namespace, binding_id))
694            .cloned())
695    }
696
697    async fn list_attention(
698        &self,
699        filter: AttentionListRequest,
700    ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
701        let guard = self.inner.read().await;
702        let mut bindings = guard
703            .attention
704            .values()
705            .filter(|binding| attention_matches_filter(binding, &filter))
706            .cloned()
707            .collect::<Vec<_>>();
708        bindings.sort_by(|left, right| {
709            left.updated_at
710                .cmp(&right.updated_at)
711                .then_with(|| left.binding_id.cmp(&right.binding_id))
712        });
713        Ok(bindings)
714    }
715
716    async fn list_attention_bounded(
717        &self,
718        filter: AttentionListRequest,
719        limit: usize,
720    ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
721        let guard = self.inner.read().await;
722        let compare = |left: &WorkAttentionBinding, right: &WorkAttentionBinding| {
723            left.updated_at
724                .cmp(&right.updated_at)
725                .then_with(|| left.binding_id.cmp(&right.binding_id))
726        };
727        let mut bindings = Vec::with_capacity(limit.min(1024));
728        for binding in guard
729            .attention
730            .values()
731            .filter(|binding| attention_matches_filter(binding, &filter))
732        {
733            let index = bindings
734                .binary_search_by(|existing| compare(existing, binding))
735                .unwrap_or_else(|index| index);
736            if index < limit {
737                bindings.insert(index, binding.clone());
738                if bindings.len() > limit {
739                    bindings.pop();
740                }
741            }
742        }
743        Ok(bindings)
744    }
745
746    async fn prune_terminal_attention(
747        &self,
748        filter: AttentionPruneRequest,
749    ) -> Result<u64, WorkGraphError> {
750        let mut guard = self.inner.write().await;
751        let before = guard.attention.len();
752        guard.attention.retain(|_, binding| {
753            let in_scope = filter
754                .realm_id
755                .as_ref()
756                .is_none_or(|realm_id| &binding.work_ref.realm_id == realm_id)
757                && filter
758                    .namespace
759                    .as_ref()
760                    .is_none_or(|namespace| &binding.work_ref.namespace == namespace)
761                && filter
762                    .updated_before
763                    .is_none_or(|updated_before| binding.updated_at < updated_before);
764            !(in_scope && binding.status.is_terminal())
765        });
766        Ok((before - guard.attention.len()) as u64)
767    }
768
769    async fn insert_edge(
770        &self,
771        edge: WorkEdge,
772        event: WorkGraphEvent,
773    ) -> Result<WorkEdge, WorkGraphError> {
774        let mut guard = self.inner.write().await;
775        if guard.edges.iter().any(|existing| existing == &edge) {
776            return Err(duplicate_edge_error(&edge));
777        }
778        guard.edges.push(edge.clone());
779        guard.append_event(event);
780        Ok(edge)
781    }
782
783    async fn insert_edge_validated(
784        &self,
785        edge: WorkEdge,
786        event: WorkGraphEvent,
787    ) -> Result<WorkEdge, WorkGraphError> {
788        let mut guard = self.inner.write().await;
789        if guard.edges.iter().any(|existing| existing == &edge) {
790            return Err(duplicate_edge_error(&edge));
791        }
792        let existing_edges = guard
793            .edges
794            .iter()
795            .filter(|existing| {
796                existing.realm_id == edge.realm_id && existing.namespace == edge.namespace
797            })
798            .cloned()
799            .collect::<Vec<_>>();
800        let existing_items = guard
801            .items
802            .values()
803            .filter(|item| item.realm_id == edge.realm_id && item.namespace == edge.namespace)
804            .cloned()
805            .collect::<Vec<_>>();
806        WorkGraphMachine::validate_link(&edge, &existing_items, &existing_edges)?;
807        guard.edges.push(edge.clone());
808        guard.append_event(event);
809        Ok(edge)
810    }
811
812    async fn list_edges(
813        &self,
814        realm_id: &str,
815        namespace: &WorkNamespace,
816    ) -> Result<Vec<WorkEdge>, WorkGraphError> {
817        let guard = self.inner.read().await;
818        Ok(guard
819            .edges
820            .iter()
821            .filter(|edge| edge.realm_id == realm_id && edge.namespace == *namespace)
822            .cloned()
823            .collect())
824    }
825
826    async fn list_edges_bounded(
827        &self,
828        realm_id: &str,
829        namespace: &WorkNamespace,
830        limit: usize,
831    ) -> Result<Vec<WorkEdge>, WorkGraphError> {
832        let guard = self.inner.read().await;
833        Ok(guard
834            .edges
835            .iter()
836            .filter(|edge| edge.realm_id == realm_id && edge.namespace == *namespace)
837            .take(limit)
838            .cloned()
839            .collect())
840    }
841
842    async fn list_events(
843        &self,
844        filter: WorkGraphEventFilter,
845    ) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
846        let guard = self.inner.read().await;
847        let events = guard
848            .events
849            .iter()
850            .filter(|event| event_matches_filter(event, &filter))
851            .take(filter.limit.unwrap_or(usize::MAX))
852            .cloned()
853            .collect::<Vec<_>>();
854        Ok(events)
855    }
856
857    async fn latest_event_seq(
858        &self,
859        filter: WorkGraphEventFilter,
860    ) -> Result<Option<i64>, WorkGraphError> {
861        let guard = self.inner.read().await;
862        Ok(guard
863            .events
864            .iter()
865            .filter(|event| event_matches_filter(event, &filter))
866            .filter_map(|event| event.seq)
867            .max())
868    }
869}
870
871impl MemoryWorkGraphState {
872    fn append_event(&mut self, mut event: WorkGraphEvent) {
873        self.next_event_seq += 1;
874        event.seq = Some(self.next_event_seq);
875        self.events.push(event);
876    }
877}
878
879fn item_key(
880    realm_id: &str,
881    namespace: &WorkNamespace,
882    id: &WorkItemId,
883) -> (String, WorkNamespace, WorkItemId) {
884    (realm_id.to_string(), namespace.clone(), id.clone())
885}
886
887fn attention_key(
888    realm_id: &str,
889    namespace: &WorkNamespace,
890    id: &WorkAttentionBindingId,
891) -> (String, WorkNamespace, WorkAttentionBindingId) {
892    (realm_id.to_string(), namespace.clone(), id.clone())
893}
894
895fn item_matches_filter(item: &WorkItem, filter: &WorkItemFilter) -> bool {
896    if let Some(realm_id) = &filter.realm_id
897        && &item.realm_id != realm_id
898    {
899        return false;
900    }
901    if !filter.all_namespaces
902        && let Some(namespace) = &filter.namespace
903        && &item.namespace != namespace
904    {
905        return false;
906    }
907    if !filter.statuses.is_empty() && !filter.statuses.contains(&item.status) {
908        return false;
909    }
910    // The terminality verdict (which lifecycle phases are terminal) is a machine
911    // fact owned by WorkGraphLifecycleMachine, not this filter. We drive the
912    // machine's ClassifyTerminality over the item's recovered state and mirror the
913    // verdict, failing closed: an item the machine cannot classify is treated as
914    // terminal so it is never surfaced as live work when terminals are excluded.
915    if !filter.include_terminal && WorkGraphMachine::classify_terminality(item).unwrap_or(true) {
916        return false;
917    }
918    filter
919        .labels
920        .iter()
921        .all(|label| item.labels.contains(label))
922}
923
924fn attention_matches_filter(binding: &WorkAttentionBinding, filter: &AttentionListRequest) -> bool {
925    if let Some(realm_id) = &filter.realm_id
926        && &binding.work_ref.realm_id != realm_id
927    {
928        return false;
929    }
930    if let Some(namespace) = &filter.namespace
931        && &binding.work_ref.namespace != namespace
932    {
933        return false;
934    }
935    if let Some(target) = &filter.target
936        && &binding.target != target
937    {
938        return false;
939    }
940    if let Some(status) = &filter.status
941        && !attention_status_matches_filter(&binding.status, status)
942    {
943        return false;
944    }
945    true
946}
947
948fn attention_status_matches_filter(
949    actual: &crate::types::WorkAttentionStatus,
950    filter: &crate::types::WorkAttentionStatus,
951) -> bool {
952    use crate::types::WorkAttentionStatus;
953
954    match (actual, filter) {
955        (WorkAttentionStatus::Active, WorkAttentionStatus::Active)
956        | (WorkAttentionStatus::Superseded, WorkAttentionStatus::Superseded)
957        | (WorkAttentionStatus::Stopped, WorkAttentionStatus::Stopped) => true,
958        (WorkAttentionStatus::Paused { .. }, WorkAttentionStatus::Paused { until: None }) => true,
959        (
960            WorkAttentionStatus::Paused {
961                until: Some(actual_until),
962            },
963            WorkAttentionStatus::Paused {
964                until: Some(filter_until),
965            },
966        ) => actual_until == filter_until,
967        _ => false,
968    }
969}
970
971fn event_matches_filter(event: &WorkGraphEvent, filter: &WorkGraphEventFilter) -> bool {
972    if let Some(after_seq) = filter.after_seq
973        && event.seq.unwrap_or_default() <= after_seq
974    {
975        return false;
976    }
977    if let Some(realm_id) = &filter.realm_id
978        && &event.realm_id != realm_id
979    {
980        return false;
981    }
982    if !filter.all_namespaces
983        && let Some(namespace) = &filter.namespace
984        && &event.namespace != namespace
985    {
986        return false;
987    }
988    true
989}
990
991#[cfg(not(target_arch = "wasm32"))]
992pub struct SqliteWorkGraphStore {
993    path: PathBuf,
994}
995
996#[cfg(not(target_arch = "wasm32"))]
997impl SqliteWorkGraphStore {
998    pub fn open(path: impl Into<PathBuf>) -> Result<Self, WorkGraphError> {
999        let store = Self { path: path.into() };
1000        store.with_connection(migrate_sqlite_attention_query_columns)?;
1001        Ok(store)
1002    }
1003
1004    pub fn path(&self) -> &Path {
1005        &self.path
1006    }
1007
1008    pub fn rebuild_projection_from_events(&self) -> Result<(), WorkGraphError> {
1009        self.with_connection(|conn| {
1010            // Rebuild is a whole-projection writer: it must acquire the write
1011            // lock before deleting projected rows so concurrent writers either
1012            // wait on busy_timeout or proceed after the rebuild commits.
1013            let tx = conn
1014                .transaction_with_behavior(TransactionBehavior::Immediate)
1015                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1016            tx.execute("DELETE FROM workgraph_items", [])
1017                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1018            tx.execute("DELETE FROM workgraph_edges", [])
1019                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1020            tx.execute("DELETE FROM workgraph_attention", [])
1021                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1022
1023            let events = {
1024                let mut stmt = tx
1025                    .prepare("SELECT event_json FROM workgraph_events ORDER BY seq ASC")
1026                    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1027                let rows = stmt
1028                    .query_map([], |row| row_json::<WorkGraphEvent>(row, 0))
1029                    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1030                let mut events = Vec::new();
1031                for row in rows {
1032                    events.push(row.map_err(|err| WorkGraphError::Store(err.to_string()))?);
1033                }
1034                events
1035            };
1036
1037            for event in events {
1038                replay_event_tx(&tx, &event)?;
1039            }
1040            normalize_attention_for_terminal_items_tx(&tx)?;
1041            tx.commit()
1042                .map_err(|err| WorkGraphError::Store(err.to_string()))
1043        })
1044    }
1045
1046    fn with_connection<T>(
1047        &self,
1048        f: impl FnOnce(&mut Connection) -> Result<T, WorkGraphError>,
1049    ) -> Result<T, WorkGraphError> {
1050        if let Some(parent) = self.path.parent() {
1051            std::fs::create_dir_all(parent)
1052                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1053        }
1054        let mut conn =
1055            Connection::open(&self.path).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1056        conn.busy_timeout(Duration::from_millis(SQLITE_BUSY_TIMEOUT_MS))
1057            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1058        conn.pragma_update(None, "journal_mode", "WAL")
1059            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1060        conn.pragma_update(None, "synchronous", "FULL")
1061            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1062        init_sqlite_schema(&conn)?;
1063        f(&mut conn)
1064    }
1065}
1066
1067#[cfg(not(target_arch = "wasm32"))]
1068#[async_trait]
1069impl WorkGraphStore for SqliteWorkGraphStore {
1070    fn kind(&self) -> WorkGraphStoreKind {
1071        WorkGraphStoreKind::Sqlite
1072    }
1073
1074    async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError> {
1075        Ok(Utc::now())
1076    }
1077
1078    async fn insert_item(
1079        &self,
1080        item: WorkItem,
1081        event: WorkGraphEvent,
1082    ) -> Result<WorkItem, WorkGraphError> {
1083        WorkGraphMachine::validate_item_projection(&item)?;
1084        self.with_connection(|conn| {
1085            let tx = conn
1086                .transaction_with_behavior(TransactionBehavior::Immediate)
1087                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1088            insert_item_tx(&tx, &item)?;
1089            insert_event_tx(&tx, &event)?;
1090            tx.commit()
1091                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1092            Ok(item)
1093        })
1094    }
1095
1096    async fn update_item_cas(
1097        &self,
1098        item: WorkItem,
1099        expected_previous_revision: u64,
1100        event: WorkGraphEvent,
1101    ) -> Result<WorkItem, WorkGraphError> {
1102        WorkGraphMachine::validate_item_projection(&item)?;
1103        self.with_connection(|conn| {
1104            let tx = conn
1105                .transaction_with_behavior(TransactionBehavior::Immediate)
1106                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1107            let changed = update_item_tx(&tx, &item, expected_previous_revision)?;
1108            if changed == 0 {
1109                let actual = current_revision_tx(&tx, &item.realm_id, &item.namespace, &item.id)?;
1110                return match actual {
1111                    Some(actual) => Err(WorkGraphError::StaleRevision {
1112                        id: item.id,
1113                        expected: expected_previous_revision,
1114                        actual,
1115                    }),
1116                    None => Err(WorkGraphError::not_found(
1117                        item.realm_id,
1118                        item.namespace,
1119                        item.id,
1120                    )),
1121                };
1122            }
1123            insert_event_tx(&tx, &event)?;
1124            tx.commit()
1125                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1126            Ok(item)
1127        })
1128    }
1129
1130    async fn get_item(
1131        &self,
1132        realm_id: &str,
1133        namespace: &WorkNamespace,
1134        id: &WorkItemId,
1135    ) -> Result<Option<WorkItem>, WorkGraphError> {
1136        self.with_connection(|conn| select_item(conn, realm_id, namespace, id))
1137    }
1138
1139    async fn list_items(&self, filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError> {
1140        self.with_connection(|conn| list_sqlite_items(conn, &filter))
1141    }
1142
1143    async fn insert_goal(
1144        &self,
1145        item: WorkItem,
1146        item_event: WorkGraphEvent,
1147        attention: WorkAttentionBinding,
1148        attention_event: WorkGraphEvent,
1149    ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
1150        WorkGraphMachine::validate_item_projection(&item)?;
1151        self.with_connection(|conn| {
1152            let tx = conn
1153                .transaction_with_behavior(TransactionBehavior::Immediate)
1154                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1155            if let Some(occupant) = active_target_occupant_tx(&tx, &attention)? {
1156                return Err(active_target_conflict(&attention, &occupant));
1157            }
1158            insert_item_tx(&tx, &item)?;
1159            insert_attention_tx(&tx, &attention)?;
1160            insert_event_tx(&tx, &item_event)?;
1161            insert_event_tx(&tx, &attention_event)?;
1162            tx.commit()
1163                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1164            Ok((item, attention))
1165        })
1166    }
1167
1168    async fn update_attention_cas(
1169        &self,
1170        attention: WorkAttentionBinding,
1171        expected_previous_revision: u64,
1172        event: WorkGraphEvent,
1173    ) -> Result<WorkAttentionBinding, WorkGraphError> {
1174        self.with_connection(|conn| {
1175            let tx = conn
1176                .transaction_with_behavior(TransactionBehavior::Immediate)
1177                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1178            let changed = update_attention_tx(&tx, &attention, expected_previous_revision)?;
1179            if changed == 0 {
1180                let actual = current_attention_revision_tx(
1181                    &tx,
1182                    &attention.work_ref.realm_id,
1183                    &attention.work_ref.namespace,
1184                    &attention.binding_id,
1185                )?;
1186                return match actual {
1187                    Some(actual) => Err(WorkGraphError::StaleRevision {
1188                        id: attention.work_ref.item_id,
1189                        expected: expected_previous_revision,
1190                        actual,
1191                    }),
1192                    None => Err(WorkGraphError::not_found(
1193                        attention.work_ref.realm_id,
1194                        attention.work_ref.namespace,
1195                        attention.work_ref.item_id,
1196                    )),
1197                };
1198            }
1199            // Occupancy after the row rewrite (the probe excludes the
1200            // candidate itself); a conflict drops the transaction, rolling
1201            // the rewrite back.
1202            if let Some(occupant) = active_target_occupant_tx(&tx, &attention)? {
1203                return Err(active_target_conflict(&attention, &occupant));
1204            }
1205            insert_event_tx(&tx, &event)?;
1206            tx.commit()
1207                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1208            Ok(attention)
1209        })
1210    }
1211
1212    async fn reassign_attention_cas(
1213        &self,
1214        previous: WorkAttentionBinding,
1215        expected_previous_revision: u64,
1216        previous_event: WorkGraphEvent,
1217        replacement: WorkAttentionBinding,
1218        replacement_event: WorkGraphEvent,
1219    ) -> Result<(WorkAttentionBinding, WorkAttentionBinding), WorkGraphError> {
1220        self.with_connection(|conn| {
1221            let tx = conn
1222                .transaction_with_behavior(TransactionBehavior::Immediate)
1223                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1224            let changed = update_attention_tx(&tx, &previous, expected_previous_revision)?;
1225            if changed == 0 {
1226                let actual = current_attention_revision_tx(
1227                    &tx,
1228                    &previous.work_ref.realm_id,
1229                    &previous.work_ref.namespace,
1230                    &previous.binding_id,
1231                )?;
1232                return match actual {
1233                    Some(actual) => Err(WorkGraphError::StaleRevision {
1234                        id: previous.work_ref.item_id,
1235                        expected: expected_previous_revision,
1236                        actual,
1237                    }),
1238                    None => Err(WorkGraphError::attention_not_found(
1239                        previous.work_ref.realm_id,
1240                        previous.work_ref.namespace,
1241                        previous.binding_id,
1242                    )),
1243                };
1244            }
1245            // Occupancy over the post-reassign state: `previous` was just
1246            // rewritten to Superseded inside this transaction, so the probe
1247            // no longer sees it as active.
1248            if let Some(occupant) = active_target_occupant_tx(&tx, &replacement)? {
1249                return Err(active_target_conflict(&replacement, &occupant));
1250            }
1251            insert_attention_tx(&tx, &replacement)?;
1252            insert_event_tx(&tx, &previous_event)?;
1253            insert_event_tx(&tx, &replacement_event)?;
1254            tx.commit()
1255                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1256            Ok((previous, replacement))
1257        })
1258    }
1259
1260    async fn update_item_and_attention_cas(
1261        &self,
1262        item: WorkItem,
1263        expected_previous_revision: u64,
1264        item_event: WorkGraphEvent,
1265        attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
1266    ) -> Result<WorkItem, WorkGraphError> {
1267        WorkGraphMachine::validate_item_projection(&item)?;
1268        self.with_connection(|conn| {
1269            let tx = conn
1270                .transaction_with_behavior(TransactionBehavior::Immediate)
1271                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1272            let changed = update_item_tx(&tx, &item, expected_previous_revision)?;
1273            if changed == 0 {
1274                let actual = current_revision_tx(&tx, &item.realm_id, &item.namespace, &item.id)?;
1275                return match actual {
1276                    Some(actual) => Err(WorkGraphError::StaleRevision {
1277                        id: item.id,
1278                        expected: expected_previous_revision,
1279                        actual,
1280                    }),
1281                    None => Err(WorkGraphError::not_found(
1282                        item.realm_id,
1283                        item.namespace,
1284                        item.id,
1285                    )),
1286                };
1287            }
1288            insert_event_tx(&tx, &item_event)?;
1289            for (attention, expected_revision, event) in &attention_updates {
1290                let changed = update_attention_tx(&tx, attention, *expected_revision)?;
1291                if changed == 0 {
1292                    let actual = current_attention_revision_tx(
1293                        &tx,
1294                        &attention.work_ref.realm_id,
1295                        &attention.work_ref.namespace,
1296                        &attention.binding_id,
1297                    )?;
1298                    return match actual {
1299                        Some(actual) => Err(WorkGraphError::StaleRevision {
1300                            id: attention.work_ref.item_id.clone(),
1301                            expected: *expected_revision,
1302                            actual,
1303                        }),
1304                        None => Err(WorkGraphError::not_found(
1305                            attention.work_ref.realm_id.clone(),
1306                            attention.work_ref.namespace.clone(),
1307                            attention.work_ref.item_id.clone(),
1308                        )),
1309                    };
1310                }
1311                // Occupancy after the row rewrite (the probe excludes the
1312                // candidate itself); a conflict drops the transaction.
1313                if let Some(occupant) = active_target_occupant_tx(&tx, attention)? {
1314                    return Err(active_target_conflict(attention, &occupant));
1315                }
1316                insert_event_tx(&tx, event)?;
1317            }
1318            tx.commit()
1319                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1320            Ok(item)
1321        })
1322    }
1323
1324    async fn get_attention(
1325        &self,
1326        realm_id: &str,
1327        namespace: &WorkNamespace,
1328        binding_id: &WorkAttentionBindingId,
1329    ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
1330        self.with_connection(|conn| select_attention(conn, realm_id, namespace, binding_id))
1331    }
1332
1333    async fn list_attention(
1334        &self,
1335        filter: AttentionListRequest,
1336    ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
1337        self.with_connection(|conn| list_sqlite_attention(conn, &filter, None))
1338    }
1339
1340    async fn list_attention_bounded(
1341        &self,
1342        filter: AttentionListRequest,
1343        limit: usize,
1344    ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
1345        self.with_connection(|conn| list_sqlite_attention(conn, &filter, Some(limit)))
1346    }
1347
1348    async fn prune_terminal_attention(
1349        &self,
1350        filter: AttentionPruneRequest,
1351    ) -> Result<u64, WorkGraphError> {
1352        self.with_connection(|conn| {
1353            let tx = conn
1354                .transaction_with_behavior(TransactionBehavior::Immediate)
1355                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1356            // Candidate scan is NULL-tolerant (rows written by older binaries
1357            // carry NULL status); each candidate is decoded and judged in
1358            // Rust before deletion, so only provably terminal rows go.
1359            let candidates: Vec<(String, String, String)> = {
1360                let mut stmt = tx
1361                    .prepare(
1362                        "SELECT realm_id, namespace, binding_id, attention_json
1363                           FROM workgraph_attention
1364                          WHERE status IN ('superseded', 'stopped') OR status IS NULL",
1365                    )
1366                    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1367                let rows = stmt
1368                    .query_map([], |row| {
1369                        Ok((
1370                            row.get::<_, String>(0)?,
1371                            row.get::<_, String>(1)?,
1372                            row.get::<_, String>(2)?,
1373                            row_json::<WorkAttentionBinding>(row, 3)?,
1374                        ))
1375                    })
1376                    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1377                let mut candidates = Vec::new();
1378                for row in rows {
1379                    let (realm_id, namespace, binding_id, binding) =
1380                        row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
1381                    let in_scope = filter
1382                        .realm_id
1383                        .as_ref()
1384                        .is_none_or(|realm| &binding.work_ref.realm_id == realm)
1385                        && filter
1386                            .namespace
1387                            .as_ref()
1388                            .is_none_or(|ns| &binding.work_ref.namespace == ns)
1389                        && filter
1390                            .updated_before
1391                            .is_none_or(|updated_before| binding.updated_at < updated_before);
1392                    if in_scope && binding.status.is_terminal() {
1393                        candidates.push((realm_id, namespace, binding_id));
1394                    }
1395                }
1396                candidates
1397            };
1398            let mut pruned = 0u64;
1399            for (realm_id, namespace, binding_id) in candidates {
1400                pruned += tx
1401                    .execute(
1402                        "DELETE FROM workgraph_attention
1403                          WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
1404                        params![realm_id, namespace, binding_id],
1405                    )
1406                    .map_err(|err| WorkGraphError::Store(err.to_string()))?
1407                    as u64;
1408            }
1409            tx.commit()
1410                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1411            Ok(pruned)
1412        })
1413    }
1414
1415    async fn insert_edge(
1416        &self,
1417        edge: WorkEdge,
1418        event: WorkGraphEvent,
1419    ) -> Result<WorkEdge, WorkGraphError> {
1420        self.with_connection(|conn| {
1421            let tx = conn
1422                .transaction_with_behavior(TransactionBehavior::Immediate)
1423                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1424            insert_edge_tx(&tx, &edge)?;
1425            insert_event_tx(&tx, &event)?;
1426            tx.commit()
1427                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1428            Ok(edge)
1429        })
1430    }
1431
1432    async fn insert_edge_validated(
1433        &self,
1434        edge: WorkEdge,
1435        event: WorkGraphEvent,
1436    ) -> Result<WorkEdge, WorkGraphError> {
1437        self.with_connection(|conn| {
1438            let tx = conn
1439                .transaction_with_behavior(TransactionBehavior::Immediate)
1440                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1441            let existing_edges = list_sqlite_edges(&tx, &edge.realm_id, &edge.namespace, None)?;
1442            let existing_items = list_sqlite_items(
1443                &tx,
1444                &WorkItemFilter {
1445                    realm_id: Some(edge.realm_id.clone()),
1446                    namespace: Some(edge.namespace.clone()),
1447                    include_terminal: true,
1448                    ..WorkItemFilter::default()
1449                },
1450            )?;
1451            WorkGraphMachine::validate_link(&edge, &existing_items, &existing_edges)?;
1452            insert_edge_tx(&tx, &edge)?;
1453            insert_event_tx(&tx, &event)?;
1454            tx.commit()
1455                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1456            Ok(edge)
1457        })
1458    }
1459
1460    async fn list_edges(
1461        &self,
1462        realm_id: &str,
1463        namespace: &WorkNamespace,
1464    ) -> Result<Vec<WorkEdge>, WorkGraphError> {
1465        self.with_connection(|conn| list_sqlite_edges(conn, realm_id, namespace, None))
1466    }
1467
1468    async fn list_edges_bounded(
1469        &self,
1470        realm_id: &str,
1471        namespace: &WorkNamespace,
1472        limit: usize,
1473    ) -> Result<Vec<WorkEdge>, WorkGraphError> {
1474        self.with_connection(|conn| list_sqlite_edges(conn, realm_id, namespace, Some(limit)))
1475    }
1476
1477    async fn list_events(
1478        &self,
1479        filter: WorkGraphEventFilter,
1480    ) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
1481        self.with_connection(|conn| list_sqlite_events(conn, &filter))
1482    }
1483
1484    async fn latest_event_seq(
1485        &self,
1486        filter: WorkGraphEventFilter,
1487    ) -> Result<Option<i64>, WorkGraphError> {
1488        self.with_connection(|conn| latest_sqlite_event_seq(conn, &filter))
1489    }
1490}
1491
1492#[cfg(not(target_arch = "wasm32"))]
1493fn init_sqlite_schema(conn: &Connection) -> Result<(), WorkGraphError> {
1494    conn.execute_batch(
1495        r"
1496        CREATE TABLE IF NOT EXISTS workgraph_items (
1497            realm_id TEXT NOT NULL,
1498            namespace TEXT NOT NULL,
1499            item_id TEXT NOT NULL,
1500            revision INTEGER NOT NULL,
1501            updated_at_utc TEXT NOT NULL,
1502            item_json TEXT NOT NULL,
1503            PRIMARY KEY (realm_id, namespace, item_id)
1504        );
1505        CREATE INDEX IF NOT EXISTS idx_workgraph_items_realm_namespace_updated
1506            ON workgraph_items (realm_id, namespace, updated_at_utc);
1507
1508        CREATE TABLE IF NOT EXISTS workgraph_attention (
1509            realm_id TEXT NOT NULL,
1510            namespace TEXT NOT NULL,
1511            binding_id TEXT NOT NULL,
1512            revision INTEGER NOT NULL,
1513            updated_at_utc TEXT NOT NULL,
1514            attention_json TEXT NOT NULL,
1515            PRIMARY KEY (realm_id, namespace, binding_id)
1516        );
1517        CREATE INDEX IF NOT EXISTS idx_workgraph_attention_realm_namespace_updated
1518            ON workgraph_attention (realm_id, namespace, updated_at_utc);
1519
1520        CREATE TABLE IF NOT EXISTS workgraph_edges (
1521            realm_id TEXT NOT NULL,
1522            namespace TEXT NOT NULL,
1523            edge_kind TEXT NOT NULL,
1524            from_id TEXT NOT NULL,
1525            to_id TEXT NOT NULL,
1526            edge_json TEXT NOT NULL,
1527            PRIMARY KEY (realm_id, namespace, edge_kind, from_id, to_id)
1528        );
1529
1530        CREATE TABLE IF NOT EXISTS workgraph_events (
1531            seq INTEGER PRIMARY KEY AUTOINCREMENT,
1532            realm_id TEXT NOT NULL,
1533            namespace TEXT NOT NULL,
1534            item_id TEXT,
1535            event_kind TEXT NOT NULL,
1536            at_utc TEXT NOT NULL,
1537            event_json TEXT NOT NULL
1538        );
1539        CREATE INDEX IF NOT EXISTS idx_workgraph_events_realm_namespace_seq
1540            ON workgraph_events (realm_id, namespace, seq);
1541        ",
1542    )
1543    .map_err(|err| WorkGraphError::Store(err.to_string()))
1544}
1545
1546#[cfg(not(target_arch = "wasm32"))]
1547fn insert_item_tx(tx: &Transaction<'_>, item: &WorkItem) -> Result<(), WorkGraphError> {
1548    let json = serde_json::to_string(item).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1549    tx.execute(
1550        "INSERT INTO workgraph_items (realm_id, namespace, item_id, revision, updated_at_utc, item_json)
1551         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1552        params![
1553            item.realm_id,
1554            item.namespace.as_str(),
1555            item.id.as_str(),
1556            item.revision,
1557            item.updated_at.to_rfc3339(),
1558            json,
1559        ],
1560    )
1561    .map_err(|err| map_sqlite_insert_item_error(err, item))?;
1562    Ok(())
1563}
1564
1565#[cfg(not(target_arch = "wasm32"))]
1566fn update_item_tx(
1567    tx: &Transaction<'_>,
1568    item: &WorkItem,
1569    expected_previous_revision: u64,
1570) -> Result<usize, WorkGraphError> {
1571    let json = serde_json::to_string(item).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1572    tx.execute(
1573        "UPDATE workgraph_items
1574            SET revision = ?4, updated_at_utc = ?5, item_json = ?6
1575          WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3 AND revision = ?7",
1576        params![
1577            item.realm_id,
1578            item.namespace.as_str(),
1579            item.id.as_str(),
1580            item.revision,
1581            item.updated_at.to_rfc3339(),
1582            json,
1583            expected_previous_revision,
1584        ],
1585    )
1586    .map_err(|err| WorkGraphError::Store(err.to_string()))
1587}
1588
1589#[cfg(not(target_arch = "wasm32"))]
1590fn upsert_item_tx(tx: &Transaction<'_>, item: &WorkItem) -> Result<(), WorkGraphError> {
1591    let json = serde_json::to_string(item).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1592    tx.execute(
1593        "INSERT INTO workgraph_items
1594            (realm_id, namespace, item_id, revision, updated_at_utc, item_json)
1595         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1596         ON CONFLICT(realm_id, namespace, item_id) DO UPDATE SET
1597            revision = excluded.revision,
1598            updated_at_utc = excluded.updated_at_utc,
1599            item_json = excluded.item_json",
1600        params![
1601            item.realm_id,
1602            item.namespace.as_str(),
1603            item.id.as_str(),
1604            item.revision,
1605            item.updated_at.to_rfc3339(),
1606            json,
1607        ],
1608    )
1609    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1610    Ok(())
1611}
1612
1613#[cfg(not(target_arch = "wasm32"))]
1614fn map_sqlite_insert_item_error(err: Error, item: &WorkItem) -> WorkGraphError {
1615    if sqlite_constraint_violation(&err) {
1616        return WorkGraphError::Conflict(format!("work item {} already exists", item.id));
1617    }
1618    WorkGraphError::Store(err.to_string())
1619}
1620
1621#[cfg(not(target_arch = "wasm32"))]
1622fn map_sqlite_insert_attention_error(
1623    err: Error,
1624    attention: &WorkAttentionBinding,
1625) -> WorkGraphError {
1626    if sqlite_constraint_violation(&err) {
1627        return WorkGraphError::Conflict(format!(
1628            "work attention binding {} already exists",
1629            attention.binding_id
1630        ));
1631    }
1632    WorkGraphError::Store(err.to_string())
1633}
1634
1635#[cfg(not(target_arch = "wasm32"))]
1636fn sqlite_constraint_violation(err: &Error) -> bool {
1637    matches!(
1638        err,
1639        Error::SqliteFailure(sqlite_error, _)
1640            if sqlite_error.code == ErrorCode::ConstraintViolation
1641    )
1642}
1643
1644#[cfg(not(target_arch = "wasm32"))]
1645fn current_revision_tx(
1646    tx: &Transaction<'_>,
1647    realm_id: &str,
1648    namespace: &WorkNamespace,
1649    id: &WorkItemId,
1650) -> Result<Option<u64>, WorkGraphError> {
1651    tx.query_row(
1652        "SELECT revision FROM workgraph_items WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
1653        params![realm_id, namespace.as_str(), id.as_str()],
1654        |row| row.get::<_, u64>(0),
1655    )
1656    .optional()
1657    .map_err(|err| WorkGraphError::Store(err.to_string()))
1658}
1659
1660#[cfg(not(target_arch = "wasm32"))]
1661fn insert_attention_tx(
1662    tx: &Transaction<'_>,
1663    attention: &WorkAttentionBinding,
1664) -> Result<(), WorkGraphError> {
1665    let json =
1666        serde_json::to_string(attention).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1667    tx.execute(
1668        "INSERT INTO workgraph_attention
1669            (realm_id, namespace, binding_id, revision, updated_at_utc, attention_json,
1670             status, target_key)
1671         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1672        params![
1673            attention.work_ref.realm_id,
1674            attention.work_ref.namespace.as_str(),
1675            attention.binding_id.as_str(),
1676            attention.machine_state.revision,
1677            attention.updated_at.to_rfc3339(),
1678            json,
1679            attention.status.status_key(),
1680            attention.target.target_key(),
1681        ],
1682    )
1683    .map_err(|err| map_sqlite_insert_attention_error(err, attention))?;
1684    Ok(())
1685}
1686
1687#[cfg(not(target_arch = "wasm32"))]
1688fn update_attention_tx(
1689    tx: &Transaction<'_>,
1690    attention: &WorkAttentionBinding,
1691    expected_previous_revision: u64,
1692) -> Result<usize, WorkGraphError> {
1693    let json =
1694        serde_json::to_string(attention).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1695    tx.execute(
1696        "UPDATE workgraph_attention
1697            SET revision = ?4, updated_at_utc = ?5, attention_json = ?6,
1698                status = ?8, target_key = ?9
1699          WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3 AND revision = ?7",
1700        params![
1701            attention.work_ref.realm_id,
1702            attention.work_ref.namespace.as_str(),
1703            attention.binding_id.as_str(),
1704            attention.machine_state.revision,
1705            attention.updated_at.to_rfc3339(),
1706            json,
1707            expected_previous_revision,
1708            attention.status.status_key(),
1709            attention.target.target_key(),
1710        ],
1711    )
1712    .map_err(|err| WorkGraphError::Store(err.to_string()))
1713}
1714
1715/// One-time, idempotent migration adding the indexed `status` / `target_key`
1716/// query columns to `workgraph_attention` (SQL filter pushdown + the
1717/// active-binding-per-target occupancy guard) and backfilling existing rows.
1718/// Rows written by OLDER binaries after this migration carry NULL columns:
1719/// every reader of these columns is NULL-tolerant and falls back to decoding
1720/// `attention_json`, so mixed-version shared stores stay correct.
1721#[cfg(not(target_arch = "wasm32"))]
1722fn migrate_sqlite_attention_query_columns(conn: &mut Connection) -> Result<(), WorkGraphError> {
1723    for alter in [
1724        "ALTER TABLE workgraph_attention ADD COLUMN status TEXT",
1725        "ALTER TABLE workgraph_attention ADD COLUMN target_key TEXT",
1726    ] {
1727        if let Err(err) = conn.execute(alter, []) {
1728            let message = err.to_string();
1729            if !message.contains("duplicate column name") {
1730                return Err(WorkGraphError::Store(message));
1731            }
1732        }
1733    }
1734    let backfill: Vec<(String, String, String, WorkAttentionBinding)> = {
1735        let mut stmt = conn
1736            .prepare(
1737                "SELECT realm_id, namespace, binding_id, attention_json
1738                   FROM workgraph_attention
1739                  WHERE status IS NULL OR target_key IS NULL",
1740            )
1741            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1742        let rows = stmt
1743            .query_map([], |row| {
1744                Ok((
1745                    row.get::<_, String>(0)?,
1746                    row.get::<_, String>(1)?,
1747                    row.get::<_, String>(2)?,
1748                    row_json::<WorkAttentionBinding>(row, 3)?,
1749                ))
1750            })
1751            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1752        let mut backfill = Vec::new();
1753        for row in rows {
1754            backfill.push(row.map_err(|err| WorkGraphError::Store(err.to_string()))?);
1755        }
1756        backfill
1757    };
1758    for (realm_id, namespace, binding_id, binding) in backfill {
1759        conn.execute(
1760            "UPDATE workgraph_attention
1761                SET status = ?4, target_key = ?5
1762              WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
1763            params![
1764                realm_id,
1765                namespace,
1766                binding_id,
1767                binding.status.status_key(),
1768                binding.target.target_key(),
1769            ],
1770        )
1771        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1772    }
1773    conn.execute(
1774        "CREATE INDEX IF NOT EXISTS idx_workgraph_attention_scope_status
1775             ON workgraph_attention (realm_id, namespace, status, target_key)",
1776        [],
1777    )
1778    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1779    Ok(())
1780}
1781
1782/// Occupancy probe for the active-binding-per-target invariant, run INSIDE
1783/// the same immediate write transaction as the mutation it guards so the
1784/// check is race-free next to the data. NULL-column rows (written by older
1785/// binaries) are decoded from JSON before judging, so mixed-version stores
1786/// cannot dodge the guard.
1787#[cfg(not(target_arch = "wasm32"))]
1788fn active_target_occupant_tx(
1789    tx: &Transaction<'_>,
1790    candidate: &WorkAttentionBinding,
1791) -> Result<Option<WorkAttentionBindingId>, WorkGraphError> {
1792    if !matches!(candidate.status, WorkAttentionStatus::Active) {
1793        return Ok(None);
1794    }
1795    let target_key = candidate.target.target_key();
1796    let mut stmt = tx
1797        .prepare(
1798            "SELECT binding_id, attention_json FROM workgraph_attention
1799              WHERE realm_id = ?1 AND namespace = ?2 AND binding_id != ?3
1800                AND (status = 'active' OR status IS NULL)
1801                AND (target_key = ?4 OR target_key IS NULL)",
1802        )
1803        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1804    let rows = stmt
1805        .query_map(
1806            params![
1807                candidate.work_ref.realm_id,
1808                candidate.work_ref.namespace.as_str(),
1809                candidate.binding_id.as_str(),
1810                target_key,
1811            ],
1812            |row| {
1813                Ok((
1814                    row.get::<_, String>(0)?,
1815                    row_json::<WorkAttentionBinding>(row, 1)?,
1816                ))
1817            },
1818        )
1819        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1820    for row in rows {
1821        let (_, binding) = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
1822        if matches!(binding.status, WorkAttentionStatus::Active)
1823            && binding.target.target_key() == target_key
1824        {
1825            return Ok(Some(binding.binding_id));
1826        }
1827    }
1828    Ok(None)
1829}
1830
1831/// Typed conflict naming the occupant, so hosts get the invariant they were
1832/// building by hand (mobkit admission guards demote to defense-in-depth).
1833fn active_target_conflict(
1834    candidate: &WorkAttentionBinding,
1835    occupant: &WorkAttentionBindingId,
1836) -> WorkGraphError {
1837    WorkGraphError::Conflict(format!(
1838        "active attention binding {occupant} already targets {} in {}/{}",
1839        candidate.target.target_key(),
1840        candidate.work_ref.realm_id,
1841        candidate.work_ref.namespace.as_str(),
1842    ))
1843}
1844
1845/// Memory-store twin of [`active_target_occupant_tx`], run under the store's
1846/// write lock.
1847fn active_target_occupant_in<'a>(
1848    bindings: impl Iterator<Item = &'a WorkAttentionBinding>,
1849    candidate: &WorkAttentionBinding,
1850) -> Option<WorkAttentionBindingId> {
1851    if !matches!(candidate.status, WorkAttentionStatus::Active) {
1852        return None;
1853    }
1854    let target_key = candidate.target.target_key();
1855    bindings
1856        .filter(|binding| {
1857            binding.binding_id != candidate.binding_id
1858                && binding.work_ref.realm_id == candidate.work_ref.realm_id
1859                && binding.work_ref.namespace == candidate.work_ref.namespace
1860                && matches!(binding.status, WorkAttentionStatus::Active)
1861                && binding.target.target_key() == target_key
1862        })
1863        .map(|binding| binding.binding_id.clone())
1864        .next()
1865}
1866
1867#[cfg(not(target_arch = "wasm32"))]
1868fn upsert_attention_tx(
1869    tx: &Transaction<'_>,
1870    attention: &WorkAttentionBinding,
1871) -> Result<(), WorkGraphError> {
1872    let json =
1873        serde_json::to_string(attention).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1874    tx.execute(
1875        "INSERT INTO workgraph_attention
1876            (realm_id, namespace, binding_id, revision, updated_at_utc, attention_json,
1877             status, target_key)
1878         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1879         ON CONFLICT(realm_id, namespace, binding_id) DO UPDATE SET
1880            revision = excluded.revision,
1881            updated_at_utc = excluded.updated_at_utc,
1882            attention_json = excluded.attention_json,
1883            status = excluded.status,
1884            target_key = excluded.target_key",
1885        params![
1886            attention.work_ref.realm_id,
1887            attention.work_ref.namespace.as_str(),
1888            attention.binding_id.as_str(),
1889            attention.machine_state.revision,
1890            attention.updated_at.to_rfc3339(),
1891            json,
1892            attention.status.status_key(),
1893            attention.target.target_key(),
1894        ],
1895    )
1896    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1897    Ok(())
1898}
1899
1900#[cfg(not(target_arch = "wasm32"))]
1901fn current_attention_revision_tx(
1902    tx: &Transaction<'_>,
1903    realm_id: &str,
1904    namespace: &WorkNamespace,
1905    binding_id: &WorkAttentionBindingId,
1906) -> Result<Option<u64>, WorkGraphError> {
1907    tx.query_row(
1908        "SELECT revision FROM workgraph_attention
1909         WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
1910        params![realm_id, namespace.as_str(), binding_id.as_str()],
1911        |row| row.get::<_, u64>(0),
1912    )
1913    .optional()
1914    .map_err(|err| WorkGraphError::Store(err.to_string()))
1915}
1916
1917#[cfg(not(target_arch = "wasm32"))]
1918fn insert_edge_tx(tx: &Transaction<'_>, edge: &WorkEdge) -> Result<(), WorkGraphError> {
1919    let json = serde_json::to_string(edge).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1920    tx.execute(
1921        "INSERT INTO workgraph_edges
1922            (realm_id, namespace, edge_kind, from_id, to_id, edge_json)
1923         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1924        params![
1925            edge.realm_id,
1926            edge.namespace.as_str(),
1927            format!("{:?}", edge.kind),
1928            edge.from_id.as_str(),
1929            edge.to_id.as_str(),
1930            json,
1931        ],
1932    )
1933    .map_err(|err| map_sqlite_insert_edge_error(err, edge))?;
1934    Ok(())
1935}
1936
1937fn duplicate_edge_error(edge: &WorkEdge) -> WorkGraphError {
1938    WorkGraphError::Conflict(format!(
1939        "work edge {:?} {} -> {} already exists",
1940        edge.kind, edge.from_id, edge.to_id
1941    ))
1942}
1943
1944#[cfg(not(target_arch = "wasm32"))]
1945fn map_sqlite_insert_edge_error(err: rusqlite::Error, edge: &WorkEdge) -> WorkGraphError {
1946    match err {
1947        rusqlite::Error::SqliteFailure(failure, _)
1948            if failure.code == ErrorCode::ConstraintViolation =>
1949        {
1950            duplicate_edge_error(edge)
1951        }
1952        err => WorkGraphError::Store(err.to_string()),
1953    }
1954}
1955
1956#[cfg(not(target_arch = "wasm32"))]
1957fn insert_event_tx(tx: &Transaction<'_>, event: &WorkGraphEvent) -> Result<(), WorkGraphError> {
1958    let json =
1959        serde_json::to_string(event).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1960    tx.execute(
1961        "INSERT INTO workgraph_events
1962            (realm_id, namespace, item_id, event_kind, at_utc, event_json)
1963         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1964        params![
1965            event.realm_id,
1966            event.namespace.as_str(),
1967            event.item_id.as_ref().map(WorkItemId::as_str),
1968            format!("{:?}", event.kind),
1969            event.at.to_rfc3339(),
1970            json,
1971        ],
1972    )
1973    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1974    Ok(())
1975}
1976
1977#[cfg(not(target_arch = "wasm32"))]
1978fn select_item(
1979    conn: &Connection,
1980    realm_id: &str,
1981    namespace: &WorkNamespace,
1982    id: &WorkItemId,
1983) -> Result<Option<WorkItem>, WorkGraphError> {
1984    conn.query_row(
1985        "SELECT item_json FROM workgraph_items WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
1986        params![realm_id, namespace.as_str(), id.as_str()],
1987        |row| row_json(row, 0),
1988    )
1989    .optional()
1990    .map_err(|err| WorkGraphError::Store(err.to_string()))
1991}
1992
1993#[cfg(not(target_arch = "wasm32"))]
1994fn list_sqlite_items(
1995    conn: &Connection,
1996    filter: &WorkItemFilter,
1997) -> Result<Vec<WorkItem>, WorkGraphError> {
1998    let mut stmt = conn
1999        .prepare("SELECT item_json FROM workgraph_items ORDER BY updated_at_utc ASC, item_id ASC")
2000        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2001    let rows = stmt
2002        .query_map([], |row| row_json::<WorkItem>(row, 0))
2003        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2004    let mut items = Vec::new();
2005    for row in rows {
2006        let item = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
2007        if item_matches_filter(&item, filter) {
2008            items.push(item);
2009            if filter.limit.is_some_and(|limit| items.len() >= limit) {
2010                break;
2011            }
2012        }
2013    }
2014    Ok(items)
2015}
2016
2017#[cfg(not(target_arch = "wasm32"))]
2018fn select_attention(
2019    conn: &Connection,
2020    realm_id: &str,
2021    namespace: &WorkNamespace,
2022    binding_id: &WorkAttentionBindingId,
2023) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
2024    conn.query_row(
2025        "SELECT attention_json FROM workgraph_attention
2026         WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
2027        params![realm_id, namespace.as_str(), binding_id.as_str()],
2028        |row| row_json(row, 0),
2029    )
2030    .optional()
2031    .map_err(|err| WorkGraphError::Store(err.to_string()))
2032}
2033
2034#[cfg(not(target_arch = "wasm32"))]
2035fn list_sqlite_attention(
2036    conn: &Connection,
2037    filter: &AttentionListRequest,
2038    limit: Option<usize>,
2039) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
2040    if limit == Some(0) {
2041        return Ok(Vec::new());
2042    }
2043    // SQL filter pushdown over the indexed query columns. Every predicate is
2044    // NULL-tolerant: rows written by older binaries carry NULL status /
2045    // target_key and must still reach the Rust-side filter, which remains the
2046    // final authority over every returned row.
2047    let mut clauses: Vec<String> = Vec::new();
2048    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2049    if let Some(realm_id) = &filter.realm_id {
2050        params.push(Box::new(realm_id.clone()));
2051        clauses.push(format!("realm_id = ?{}", params.len()));
2052    }
2053    if let Some(namespace) = &filter.namespace {
2054        params.push(Box::new(namespace.as_str().to_string()));
2055        clauses.push(format!("namespace = ?{}", params.len()));
2056    }
2057    if let Some(status) = &filter.status {
2058        params.push(Box::new(status.status_key().to_string()));
2059        clauses.push(format!("(status = ?{} OR status IS NULL)", params.len()));
2060    }
2061    if let Some(target) = &filter.target {
2062        params.push(Box::new(target.target_key()));
2063        clauses.push(format!(
2064            "(target_key = ?{} OR target_key IS NULL)",
2065            params.len()
2066        ));
2067    }
2068    let where_clause = if clauses.is_empty() {
2069        String::new()
2070    } else {
2071        format!(" WHERE {}", clauses.join(" AND "))
2072    };
2073    let sql = format!(
2074        "SELECT attention_json FROM workgraph_attention{where_clause}
2075         ORDER BY updated_at_utc ASC, binding_id ASC"
2076    );
2077    let mut stmt = conn
2078        .prepare(&sql)
2079        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2080    let rows = stmt
2081        .query_map(rusqlite::params_from_iter(params.iter()), |row| {
2082            row_json::<WorkAttentionBinding>(row, 0)
2083        })
2084        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2085    let mut bindings = Vec::new();
2086    for row in rows {
2087        let binding = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
2088        if attention_matches_filter(&binding, filter) {
2089            bindings.push(binding);
2090            if limit.is_some_and(|limit| bindings.len() >= limit) {
2091                break;
2092            }
2093        }
2094    }
2095    Ok(bindings)
2096}
2097
2098#[cfg(not(target_arch = "wasm32"))]
2099fn list_sqlite_edges(
2100    conn: &Connection,
2101    realm_id: &str,
2102    namespace: &WorkNamespace,
2103    limit: Option<usize>,
2104) -> Result<Vec<WorkEdge>, WorkGraphError> {
2105    if limit == Some(0) {
2106        return Ok(Vec::new());
2107    }
2108    let mut stmt = conn
2109        .prepare(
2110            "SELECT edge_json FROM workgraph_edges
2111             WHERE realm_id = ?1 AND namespace = ?2
2112             ORDER BY edge_kind ASC, from_id ASC, to_id ASC",
2113        )
2114        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2115    let rows = stmt
2116        .query_map(params![realm_id, namespace.as_str()], |row| {
2117            row_json::<WorkEdge>(row, 0)
2118        })
2119        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2120    let mut edges = Vec::new();
2121    for row in rows {
2122        edges.push(row.map_err(|err| WorkGraphError::Store(err.to_string()))?);
2123        if limit.is_some_and(|limit| edges.len() >= limit) {
2124            break;
2125        }
2126    }
2127    Ok(edges)
2128}
2129
2130#[cfg(not(target_arch = "wasm32"))]
2131fn list_sqlite_events(
2132    conn: &Connection,
2133    filter: &WorkGraphEventFilter,
2134) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
2135    let mut stmt = conn
2136        .prepare("SELECT seq, event_json FROM workgraph_events ORDER BY seq ASC")
2137        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2138    let rows = stmt
2139        .query_map([], |row| {
2140            let seq = row.get::<_, i64>(0)?;
2141            let mut event = row_json::<WorkGraphEvent>(row, 1)?;
2142            event.seq = Some(seq);
2143            Ok(event)
2144        })
2145        .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2146    let mut events = Vec::new();
2147    for row in rows {
2148        let event = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
2149        if event_matches_filter(&event, filter) {
2150            events.push(event);
2151            if filter.limit.is_some_and(|limit| events.len() >= limit) {
2152                break;
2153            }
2154        }
2155    }
2156    Ok(events)
2157}
2158
2159#[cfg(not(target_arch = "wasm32"))]
2160fn latest_sqlite_event_seq(
2161    conn: &Connection,
2162    filter: &WorkGraphEventFilter,
2163) -> Result<Option<i64>, WorkGraphError> {
2164    let mut clauses: Vec<String> = Vec::new();
2165    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2166    if let Some(realm_id) = &filter.realm_id {
2167        params.push(Box::new(realm_id.clone()));
2168        clauses.push(format!("realm_id = ?{}", params.len()));
2169    }
2170    if !filter.all_namespaces
2171        && let Some(namespace) = &filter.namespace
2172    {
2173        params.push(Box::new(namespace.as_str().to_string()));
2174        clauses.push(format!("namespace = ?{}", params.len()));
2175    }
2176    if let Some(after_seq) = filter.after_seq {
2177        params.push(Box::new(after_seq));
2178        clauses.push(format!("seq > ?{}", params.len()));
2179    }
2180    let where_clause = if clauses.is_empty() {
2181        String::new()
2182    } else {
2183        format!(" WHERE {}", clauses.join(" AND "))
2184    };
2185    conn.query_row(
2186        &format!("SELECT MAX(seq) FROM workgraph_events{where_clause}"),
2187        rusqlite::params_from_iter(params.iter()),
2188        |row| row.get::<_, Option<i64>>(0),
2189    )
2190    .map_err(|error| WorkGraphError::Store(error.to_string()))
2191}
2192
2193#[cfg(not(target_arch = "wasm32"))]
2194fn replay_event_tx(tx: &Transaction<'_>, event: &WorkGraphEvent) -> Result<(), WorkGraphError> {
2195    match event.kind {
2196        WorkGraphEventKind::Linked => {
2197            let edge = payload_field::<WorkEdge>(event, "edge")?;
2198            insert_edge_tx(tx, &edge)
2199        }
2200        WorkGraphEventKind::AttentionCreated | WorkGraphEventKind::AttentionUpdated => {
2201            let attention = payload_field::<WorkAttentionBinding>(event, "attention")?;
2202            upsert_attention_tx(tx, &attention)
2203        }
2204        WorkGraphEventKind::Created
2205        | WorkGraphEventKind::Updated
2206        | WorkGraphEventKind::Claimed
2207        | WorkGraphEventKind::Released
2208        | WorkGraphEventKind::Blocked
2209        | WorkGraphEventKind::Closed
2210        | WorkGraphEventKind::EvidenceAdded => {
2211            let item = payload_field::<WorkItem>(event, "item")?;
2212            upsert_item_tx(tx, &item)
2213        }
2214    }
2215}
2216
2217#[cfg(not(target_arch = "wasm32"))]
2218fn normalize_attention_for_terminal_items_tx(tx: &Transaction<'_>) -> Result<(), WorkGraphError> {
2219    let bindings = {
2220        let mut stmt = tx
2221            .prepare("SELECT attention_json FROM workgraph_attention")
2222            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2223        let rows = stmt
2224            .query_map([], |row| row_json::<WorkAttentionBinding>(row, 0))
2225            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2226        let mut bindings = Vec::new();
2227        for row in rows {
2228            bindings.push(row.map_err(|err| WorkGraphError::Store(err.to_string()))?);
2229        }
2230        bindings
2231    };
2232
2233    for binding in bindings {
2234        if matches!(
2235            binding.status,
2236            WorkAttentionStatus::Stopped | WorkAttentionStatus::Superseded
2237        ) {
2238            continue;
2239        }
2240        let item = tx
2241            .query_row(
2242                "SELECT item_json FROM workgraph_items
2243                 WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
2244                params![
2245                    binding.work_ref.realm_id,
2246                    binding.work_ref.namespace.as_str(),
2247                    binding.work_ref.item_id.as_str(),
2248                ],
2249                |row| row_json::<WorkItem>(row, 0),
2250            )
2251            .optional()
2252            .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2253        let Some(item) = item else {
2254            continue;
2255        };
2256        // Terminality is a WorkGraph machine fact: the shell mirrors the
2257        // canonical classify verdict rather than re-deciding `is_terminal()`.
2258        if WorkGraphMachine::classify_terminality(&item)? {
2259            let expected_revision = binding.machine_state.revision;
2260            let stopped = WorkAttentionMachine::stop(binding, expected_revision, item.updated_at)?;
2261            upsert_attention_tx(tx, &stopped)?;
2262        }
2263    }
2264    Ok(())
2265}
2266
2267#[cfg(not(target_arch = "wasm32"))]
2268fn payload_field<T: serde::de::DeserializeOwned>(
2269    event: &WorkGraphEvent,
2270    field: &str,
2271) -> Result<T, WorkGraphError> {
2272    let value = event.payload.get(field).ok_or_else(|| {
2273        WorkGraphError::Store(format!(
2274            "workgraph event {:?} missing payload field `{field}`",
2275            event.kind
2276        ))
2277    })?;
2278    serde_json::from_value(value.clone()).map_err(|err| WorkGraphError::Store(err.to_string()))
2279}
2280
2281#[cfg(not(target_arch = "wasm32"))]
2282fn row_json<T: serde::de::DeserializeOwned>(
2283    row: &rusqlite::Row<'_>,
2284    index: usize,
2285) -> rusqlite::Result<T> {
2286    let json = row.get::<_, String>(index)?;
2287    serde_json::from_str(&json).map_err(|err| {
2288        rusqlite::Error::FromSqlConversionFailure(index, rusqlite::types::Type::Text, Box::new(err))
2289    })
2290}
2291
2292#[cfg(test)]
2293#[allow(clippy::expect_used, clippy::unwrap_used)]
2294mod tests {
2295    use std::collections::BTreeSet;
2296
2297    use chrono::Utc;
2298    use serde_json::json;
2299
2300    use crate::types::WorkEdge;
2301    use crate::{
2302        AttentionDelegatedAuthority, AttentionProjectionPolicy, CreateWorkItemRequest,
2303        GoalAttentionTarget, GoalCreateRequest, GoalRequestCloseRequest, GoalTerminalStatus,
2304        LinkWorkItemsRequest, MemoryWorkGraphStore, WorkAttentionMode, WorkAttentionStatus,
2305        WorkCompletionPolicy, WorkEdgeKind, WorkGraphError, WorkGraphEvent, WorkGraphEventFilter,
2306        WorkGraphEventKind, WorkGraphService, WorkGraphStore, WorkItemFilter, WorkItemId,
2307        WorkNamespace,
2308    };
2309
2310    fn test_edge() -> WorkEdge {
2311        WorkEdge {
2312            realm_id: "realm".to_string(),
2313            namespace: WorkNamespace::default(),
2314            kind: WorkEdgeKind::Blocks,
2315            from_id: WorkItemId::generated(),
2316            to_id: WorkItemId::generated(),
2317            created_at: Utc::now(),
2318        }
2319    }
2320
2321    fn link_event(edge: &WorkEdge) -> WorkGraphEvent {
2322        WorkGraphEvent::graph(
2323            edge.realm_id.clone(),
2324            edge.namespace.clone(),
2325            WorkGraphEventKind::Linked,
2326            edge.created_at,
2327            json!({ "edge": edge }),
2328        )
2329    }
2330
2331    #[tokio::test]
2332    async fn memory_store_namespace_filters_do_not_leak() {
2333        let store = std::sync::Arc::new(MemoryWorkGraphStore::new());
2334        let default_service =
2335            WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2336        let other_service = WorkGraphService::with_scope(
2337            store.clone(),
2338            "realm",
2339            WorkNamespace::new("other").expect("namespace"),
2340        );
2341        default_service
2342            .create(CreateWorkItemRequest {
2343                realm_id: None,
2344                namespace: None,
2345                title: "default".to_string(),
2346                description: None,
2347                priority: Default::default(),
2348                completion_policy: Default::default(),
2349                labels: BTreeSet::new(),
2350                due_at: None,
2351                not_before: None,
2352                snoozed_until: None,
2353                external_refs: Vec::new(),
2354                evidence_refs: Vec::new(),
2355                status: None,
2356            })
2357            .await
2358            .expect("create default");
2359        other_service
2360            .create(CreateWorkItemRequest {
2361                realm_id: None,
2362                namespace: None,
2363                title: "other".to_string(),
2364                description: None,
2365                priority: Default::default(),
2366                completion_policy: Default::default(),
2367                labels: BTreeSet::new(),
2368                due_at: None,
2369                not_before: None,
2370                snoozed_until: None,
2371                external_refs: Vec::new(),
2372                evidence_refs: Vec::new(),
2373                status: None,
2374            })
2375            .await
2376            .expect("create other");
2377
2378        let items = store
2379            .list_items(WorkItemFilter {
2380                realm_id: Some("realm".to_string()),
2381                namespace: Some(WorkNamespace::default()),
2382                ..WorkItemFilter::default()
2383            })
2384            .await
2385            .expect("list");
2386        assert_eq!(items.len(), 1);
2387        assert_eq!(items[0].title, "default");
2388    }
2389
2390    #[tokio::test]
2391    async fn memory_store_duplicate_edge_does_not_append_event() {
2392        let store = MemoryWorkGraphStore::new();
2393        let edge = test_edge();
2394        store
2395            .insert_edge(edge.clone(), link_event(&edge))
2396            .await
2397            .expect("insert edge");
2398
2399        let error = store
2400            .insert_edge(edge.clone(), link_event(&edge))
2401            .await
2402            .expect_err("duplicate edge should fail");
2403        assert!(matches!(error, WorkGraphError::Conflict(_)));
2404
2405        let events = store
2406            .list_events(WorkGraphEventFilter {
2407                realm_id: Some(edge.realm_id),
2408                namespace: Some(edge.namespace),
2409                all_namespaces: false,
2410                after_seq: None,
2411                limit: None,
2412            })
2413            .await
2414            .expect("events");
2415        assert_eq!(events.len(), 1);
2416    }
2417
2418    /// Pins the SQLite UNIQUE-violation mapping for duplicate item inserts:
2419    /// a second insert of an existing item id must surface as the typed
2420    /// `Conflict`, not a generic `Store` error.
2421    #[cfg(not(target_arch = "wasm32"))]
2422    #[tokio::test]
2423    async fn sqlite_store_duplicate_item_insert_maps_to_conflict() {
2424        let dir = tempfile::tempdir().expect("tempdir");
2425        let path = dir.path().join("workgraph.sqlite3");
2426        let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2427        let service =
2428            WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2429        let item = service
2430            .create(CreateWorkItemRequest {
2431                realm_id: None,
2432                namespace: None,
2433                title: "unique item".to_string(),
2434                description: None,
2435                priority: Default::default(),
2436                completion_policy: Default::default(),
2437                labels: BTreeSet::new(),
2438                due_at: None,
2439                not_before: None,
2440                snoozed_until: None,
2441                external_refs: Vec::new(),
2442                evidence_refs: Vec::new(),
2443                status: None,
2444            })
2445            .await
2446            .expect("create");
2447
2448        let event = WorkGraphEvent::graph(
2449            item.realm_id.clone(),
2450            item.namespace.clone(),
2451            WorkGraphEventKind::Created,
2452            item.created_at,
2453            json!({ "item_id": item.id }),
2454        );
2455        let error = store
2456            .insert_item(item, event)
2457            .await
2458            .expect_err("duplicate item insert must fail");
2459        assert!(
2460            matches!(error, WorkGraphError::Conflict(_)),
2461            "duplicate item insert must map to Conflict, got: {error:?}"
2462        );
2463    }
2464
2465    /// Pins the SQLite UNIQUE-violation mapping for duplicate attention
2466    /// binding inserts (via the compound goal insert): the typed `Conflict`,
2467    /// not a generic `Store` error.
2468    #[cfg(not(target_arch = "wasm32"))]
2469    #[tokio::test]
2470    async fn sqlite_store_duplicate_attention_insert_maps_to_conflict() {
2471        let dir = tempfile::tempdir().expect("tempdir");
2472        let path = dir.path().join("workgraph.sqlite3");
2473        let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2474        let service =
2475            WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2476        let goal = service
2477            .create_goal(GoalCreateRequest {
2478                realm_id: None,
2479                namespace: None,
2480                title: "unique goal".to_string(),
2481                description: None,
2482                target: GoalAttentionTarget::Session {
2483                    session_id: meerkat_core::SessionId::new(),
2484                },
2485                mode: WorkAttentionMode::Coordinate,
2486                completion_policy: WorkCompletionPolicy::SelfAttest,
2487                delegated_authority: AttentionDelegatedAuthority::AddEvidence,
2488                projection_policy: AttentionProjectionPolicy::default(),
2489            })
2490            .await
2491            .expect("create goal");
2492
2493        let mut fresh_item = goal.item.clone();
2494        fresh_item.id = WorkItemId::generated();
2495        let item_event = WorkGraphEvent::graph(
2496            fresh_item.realm_id.clone(),
2497            fresh_item.namespace.clone(),
2498            WorkGraphEventKind::Created,
2499            fresh_item.created_at,
2500            json!({ "item_id": fresh_item.id }),
2501        );
2502        let attention_event = WorkGraphEvent::graph(
2503            goal.attention.work_ref.realm_id.clone(),
2504            goal.attention.work_ref.namespace.clone(),
2505            WorkGraphEventKind::AttentionCreated,
2506            goal.attention.updated_at,
2507            json!({ "binding_id": goal.attention.binding_id }),
2508        );
2509        let error = store
2510            .insert_goal(fresh_item, item_event, goal.attention, attention_event)
2511            .await
2512            .expect_err("duplicate attention insert must fail");
2513        assert!(
2514            matches!(error, WorkGraphError::Conflict(_)),
2515            "duplicate attention insert must map to Conflict, got: {error:?}"
2516        );
2517    }
2518
2519    #[cfg(not(target_arch = "wasm32"))]
2520    #[tokio::test]
2521    async fn sqlite_persistence_survives_restart() {
2522        let dir = tempfile::tempdir().expect("tempdir");
2523        let path = dir.path().join("workgraph.sqlite3");
2524        let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2525        let service = WorkGraphService::with_scope(store, "realm", WorkNamespace::default());
2526        let item = service
2527            .create(CreateWorkItemRequest {
2528                realm_id: None,
2529                namespace: None,
2530                title: "persist me".to_string(),
2531                description: None,
2532                priority: Default::default(),
2533                completion_policy: Default::default(),
2534                labels: BTreeSet::new(),
2535                due_at: None,
2536                not_before: None,
2537                snoozed_until: None,
2538                external_refs: Vec::new(),
2539                evidence_refs: Vec::new(),
2540                status: None,
2541            })
2542            .await
2543            .expect("create");
2544
2545        let reopened = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2546        let service = WorkGraphService::with_scope(reopened, "realm", WorkNamespace::default());
2547        let fetched = service.get(None, None, item.id.clone()).await.expect("get");
2548        assert_eq!(fetched.title, "persist me");
2549    }
2550
2551    #[cfg(not(target_arch = "wasm32"))]
2552    #[tokio::test]
2553    async fn sqlite_item_without_machine_state_fails_closed_on_read() {
2554        let dir = tempfile::tempdir().expect("tempdir");
2555        let path = dir.path().join("workgraph.sqlite3");
2556        let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2557        let service =
2558            WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2559        let item = service
2560            .create(CreateWorkItemRequest {
2561                realm_id: None,
2562                namespace: None,
2563                title: "legacy item".to_string(),
2564                description: None,
2565                priority: Default::default(),
2566                completion_policy: Default::default(),
2567                labels: BTreeSet::new(),
2568                due_at: None,
2569                not_before: None,
2570                snoozed_until: None,
2571                external_refs: Vec::new(),
2572                evidence_refs: Vec::new(),
2573                status: None,
2574            })
2575            .await
2576            .expect("create");
2577
2578        store
2579            .with_connection(|conn| {
2580                let json: String = conn
2581                    .query_row(
2582                        "SELECT item_json FROM workgraph_items
2583                         WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
2584                        rusqlite::params![
2585                            &item.realm_id,
2586                            item.namespace.as_str(),
2587                            item.id.as_str()
2588                        ],
2589                        |row| row.get(0),
2590                    )
2591                    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2592                let mut value = serde_json::from_str::<serde_json::Value>(&json)
2593                    .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2594                value
2595                    .as_object_mut()
2596                    .expect("item json object")
2597                    .remove("machine_state");
2598                conn.execute(
2599                    "UPDATE workgraph_items
2600                        SET item_json = ?4
2601                      WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
2602                    rusqlite::params![
2603                        &item.realm_id,
2604                        item.namespace.as_str(),
2605                        item.id.as_str(),
2606                        serde_json::to_string(&value)
2607                            .map_err(|err| WorkGraphError::Store(err.to_string()))?
2608                    ],
2609                )
2610                .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2611                Ok(())
2612            })
2613            .expect("strip machine state");
2614
2615        // machine_state is the sole machine-owned lifecycle/revision authority.
2616        // A persisted item missing it can no longer be backfilled from projected
2617        // fields (that fabrication path was deleted); reading it must FAIL CLOSED
2618        // with a typed error rather than reconstructing machine truth.
2619        let reopened = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2620        let service = WorkGraphService::with_scope(reopened, "realm", WorkNamespace::default());
2621        let err = service
2622            .get(None, None, item.id)
2623            .await
2624            .expect_err("reading an item with no machine_state must fail closed");
2625        assert!(
2626            matches!(err, WorkGraphError::Store(_)),
2627            "expected a typed Store deserialization error, got: {err:?}"
2628        );
2629    }
2630
2631    #[cfg(not(target_arch = "wasm32"))]
2632    #[tokio::test]
2633    async fn sqlite_event_replay_rebuilds_projection() {
2634        let dir = tempfile::tempdir().expect("tempdir");
2635        let path = dir.path().join("workgraph.sqlite3");
2636        let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2637        let service =
2638            WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2639        let blocker = service
2640            .create(CreateWorkItemRequest {
2641                realm_id: None,
2642                namespace: None,
2643                title: "blocker".to_string(),
2644                description: None,
2645                priority: Default::default(),
2646                completion_policy: Default::default(),
2647                labels: BTreeSet::new(),
2648                due_at: None,
2649                not_before: None,
2650                snoozed_until: None,
2651                external_refs: Vec::new(),
2652                evidence_refs: Vec::new(),
2653                status: None,
2654            })
2655            .await
2656            .expect("create blocker");
2657        let blocked = service
2658            .create(CreateWorkItemRequest {
2659                realm_id: None,
2660                namespace: None,
2661                title: "blocked".to_string(),
2662                description: None,
2663                priority: Default::default(),
2664                completion_policy: Default::default(),
2665                labels: BTreeSet::new(),
2666                due_at: None,
2667                not_before: None,
2668                snoozed_until: None,
2669                external_refs: Vec::new(),
2670                evidence_refs: Vec::new(),
2671                status: None,
2672            })
2673            .await
2674            .expect("create blocked");
2675        service
2676            .link(LinkWorkItemsRequest {
2677                realm_id: None,
2678                namespace: None,
2679                kind: WorkEdgeKind::Blocks,
2680                from_id: blocker.id.clone(),
2681                to_id: blocked.id.clone(),
2682            })
2683            .await
2684            .expect("link");
2685
2686        store
2687            .with_connection(|conn| {
2688                conn.execute("DELETE FROM workgraph_items", [])
2689                    .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2690                conn.execute("DELETE FROM workgraph_edges", [])
2691                    .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2692                Ok(())
2693            })
2694            .expect("clear projection");
2695
2696        let empty_items = store
2697            .list_items(WorkItemFilter {
2698                realm_id: Some("realm".to_string()),
2699                namespace: Some(WorkNamespace::default()),
2700                ..WorkItemFilter::default()
2701            })
2702            .await
2703            .expect("empty list");
2704        assert!(empty_items.is_empty());
2705
2706        store
2707            .rebuild_projection_from_events()
2708            .expect("rebuild projection");
2709
2710        let rebuilt_items = store
2711            .list_items(WorkItemFilter {
2712                realm_id: Some("realm".to_string()),
2713                namespace: Some(WorkNamespace::default()),
2714                ..WorkItemFilter::default()
2715            })
2716            .await
2717            .expect("rebuilt list");
2718        assert_eq!(rebuilt_items.len(), 2);
2719        let rebuilt_edges = store
2720            .list_edges("realm", &WorkNamespace::default())
2721            .await
2722            .expect("rebuilt edges");
2723        assert_eq!(rebuilt_edges.len(), 1);
2724    }
2725
2726    #[cfg(not(target_arch = "wasm32"))]
2727    #[tokio::test]
2728    async fn sqlite_event_replay_stops_attention_for_terminal_goal_items() {
2729        let dir = tempfile::tempdir().expect("tempdir");
2730        let path = dir.path().join("workgraph.sqlite3");
2731        let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2732        let service =
2733            WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2734        let session_id = meerkat_core::SessionId::parse("019e63c2-0000-7000-8000-000000000045")
2735            .expect("session id");
2736        let goal = service
2737            .create_goal(GoalCreateRequest {
2738                realm_id: None,
2739                namespace: None,
2740                title: "terminal goal".to_string(),
2741                description: None,
2742                target: GoalAttentionTarget::Session { session_id },
2743                mode: WorkAttentionMode::Pursue,
2744                completion_policy: WorkCompletionPolicy::SelfAttest,
2745                delegated_authority: AttentionDelegatedAuthority::CloseIfPolicyAllows,
2746                projection_policy: AttentionProjectionPolicy::default(),
2747            })
2748            .await
2749            .expect("create goal");
2750        service
2751            .goal_request_close(GoalRequestCloseRequest {
2752                binding_id: goal.attention.binding_id.clone(),
2753                realm_id: None,
2754                namespace: None,
2755                expected_revision: goal.item.revision,
2756                status: GoalTerminalStatus::Completed,
2757            })
2758            .await
2759            .expect("close goal");
2760
2761        store
2762            .with_connection(|conn| {
2763                conn.execute("DELETE FROM workgraph_items", [])
2764                    .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2765                conn.execute("DELETE FROM workgraph_attention", [])
2766                    .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2767                Ok(())
2768            })
2769            .expect("clear projection");
2770
2771        store
2772            .rebuild_projection_from_events()
2773            .expect("rebuild projection");
2774
2775        let binding = store
2776            .get_attention(
2777                "realm",
2778                &WorkNamespace::default(),
2779                &goal.attention.binding_id,
2780            )
2781            .await
2782            .expect("read binding")
2783            .expect("rebuilt binding");
2784        assert_eq!(binding.status, WorkAttentionStatus::Stopped);
2785    }
2786
2787    #[cfg(not(target_arch = "wasm32"))]
2788    #[tokio::test]
2789    async fn sqlite_store_duplicate_edge_does_not_append_event() {
2790        let dir = tempfile::tempdir().expect("tempdir");
2791        let path = dir.path().join("workgraph.sqlite3");
2792        let store = crate::SqliteWorkGraphStore::open(&path).expect("open");
2793        let edge = test_edge();
2794        store
2795            .insert_edge(edge.clone(), link_event(&edge))
2796            .await
2797            .expect("insert edge");
2798
2799        let error = store
2800            .insert_edge(edge.clone(), link_event(&edge))
2801            .await
2802            .expect_err("duplicate edge should fail");
2803        assert!(matches!(error, WorkGraphError::Conflict(_)));
2804
2805        let events = store
2806            .list_events(WorkGraphEventFilter {
2807                realm_id: Some(edge.realm_id),
2808                namespace: Some(edge.namespace),
2809                all_namespaces: false,
2810                after_seq: None,
2811                limit: None,
2812            })
2813            .await
2814            .expect("events");
2815        assert_eq!(events.len(), 1);
2816    }
2817}
2818
2819#[cfg(all(test, not(target_arch = "wasm32")))]
2820#[allow(clippy::expect_used, clippy::unwrap_used)]
2821mod legacy_schema_tests {
2822    use super::*;
2823    use crate::{
2824        AttentionDelegatedAuthority, AttentionProjectionPolicy, GoalAttentionTarget,
2825        GoalCreateRequest, WorkAttentionMode, WorkCompletionPolicy, WorkGraphService,
2826    };
2827    use meerkat_core::SessionId;
2828
2829    /// Ask 24/25 migration pin: a store created by an OLDER binary (no
2830    /// status/target_key columns) is backfilled on open, and both the SQL
2831    /// filter pushdown and the occupancy guard see its legacy rows.
2832    #[tokio::test]
2833    async fn legacy_attention_rows_are_backfilled_and_guarded() {
2834        let dir = tempfile::tempdir().expect("tempdir");
2835        let path = dir.path().join("workgraph.sqlite3");
2836        let session_id = SessionId::new();
2837
2838        // Simulate the old binary: old-schema table + one active binding row
2839        // written without the query columns.
2840        {
2841            let conn = Connection::open(&path).expect("open raw");
2842            conn.execute_batch(
2843                r"
2844                CREATE TABLE workgraph_attention (
2845                    realm_id TEXT NOT NULL,
2846                    namespace TEXT NOT NULL,
2847                    binding_id TEXT NOT NULL,
2848                    revision INTEGER NOT NULL,
2849                    updated_at_utc TEXT NOT NULL,
2850                    attention_json TEXT NOT NULL,
2851                    PRIMARY KEY (realm_id, namespace, binding_id)
2852                );
2853                ",
2854            )
2855            .expect("create legacy table");
2856            let legacy = WorkAttentionBinding {
2857                binding_id: WorkAttentionBindingId::new("legacy-binding").expect("binding id"),
2858                work_ref: crate::WorkItemRef {
2859                    realm_id: "realm".to_string(),
2860                    namespace: WorkNamespace::default(),
2861                    item_id: WorkItemId::generated(),
2862                },
2863                target: crate::WorkAttentionTarget::Session {
2864                    session_id: session_id.clone(),
2865                },
2866                mode: WorkAttentionMode::Pursue,
2867                status: WorkAttentionStatus::Active,
2868                machine_state: Default::default(),
2869                delegated_authority: AttentionDelegatedAuthority::AddEvidence,
2870                projection_policy: AttentionProjectionPolicy::default(),
2871                created_at: chrono::Utc::now(),
2872                updated_at: chrono::Utc::now(),
2873            };
2874            conn.execute(
2875                "INSERT INTO workgraph_attention
2876                    (realm_id, namespace, binding_id, revision, updated_at_utc, attention_json)
2877                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2878                params![
2879                    legacy.work_ref.realm_id,
2880                    legacy.work_ref.namespace.as_str(),
2881                    legacy.binding_id.as_str(),
2882                    legacy.machine_state.revision,
2883                    legacy.updated_at.to_rfc3339(),
2884                    serde_json::to_string(&legacy).expect("serialize legacy binding"),
2885                ],
2886            )
2887            .expect("insert legacy row");
2888        }
2889
2890        // Opening the store migrates + backfills.
2891        let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2892        {
2893            let conn = Connection::open(&path).expect("reopen raw");
2894            let (status, target_key): (Option<String>, Option<String>) = conn
2895                .query_row(
2896                    "SELECT status, target_key FROM workgraph_attention
2897                      WHERE binding_id = 'legacy-binding'",
2898                    [],
2899                    |row| Ok((row.get(0)?, row.get(1)?)),
2900                )
2901                .expect("read backfilled columns");
2902            assert_eq!(status.as_deref(), Some("active"));
2903            assert_eq!(
2904                target_key.as_deref(),
2905                Some(format!("session:{session_id}").as_str())
2906            );
2907        }
2908
2909        // The occupancy guard sees the backfilled legacy row: a new active
2910        // binding on the same target conflicts.
2911        let service = WorkGraphService::with_scope(store, "realm", WorkNamespace::default());
2912        let error = service
2913            .create_goal(GoalCreateRequest {
2914                realm_id: None,
2915                namespace: None,
2916                title: "duplicate target".to_string(),
2917                description: None,
2918                target: GoalAttentionTarget::Session {
2919                    session_id: session_id.clone(),
2920                },
2921                mode: WorkAttentionMode::Pursue,
2922                completion_policy: WorkCompletionPolicy::SelfAttest,
2923                delegated_authority: AttentionDelegatedAuthority::AddEvidence,
2924                projection_policy: AttentionProjectionPolicy::default(),
2925            })
2926            .await
2927            .expect_err("legacy occupant must conflict with a new active binding");
2928        assert!(
2929            matches!(error, WorkGraphError::Conflict(_)),
2930            "expected typed Conflict, got {error:?}"
2931        );
2932    }
2933}