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