Skip to main content

atman_runtime/memory/
confession.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::{collections::HashMap, io::Write};
4
5use rusqlite::OptionalExtension;
6use serde::{Deserialize, Serialize};
7
8use crate::error::RuntimeError;
9use crate::index::AnchorIndex;
10use crate::memory::MemoryId;
11#[cfg(test)]
12use crate::memory::read_jsonl;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Confession {
16    pub id: MemoryId,
17    pub trigger: String,
18    pub rule_violated: String,
19    pub what_i_did: String,
20    pub why: String,
21    pub mitigation: String,
22    #[serde(default)]
23    pub anchors: Vec<String>,
24    pub created_at: chrono::DateTime<chrono::Utc>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ConfessionFields {
29    pub trigger: String,
30    pub rule_violated: String,
31    pub what_i_did: String,
32    pub why: String,
33    pub mitigation: String,
34}
35
36impl From<&Confession> for ConfessionFields {
37    fn from(value: &Confession) -> Self {
38        Self {
39            trigger: value.trigger.clone(),
40            rule_violated: value.rule_violated.clone(),
41            what_i_did: value.what_i_did.clone(),
42            why: value.why.clone(),
43            mitigation: value.mitigation.clone(),
44        }
45    }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum ConfessionChange {
51    Revised {
52        id: MemoryId,
53        base_revision: u64,
54        fields: ConfessionFields,
55        changed_at: chrono::DateTime<chrono::Utc>,
56    },
57    Organized {
58        id: MemoryId,
59        base_revision: u64,
60        category: String,
61        related_ids: Vec<MemoryId>,
62        changed_at: chrono::DateTime<chrono::Utc>,
63    },
64    Archived {
65        id: MemoryId,
66        base_revision: u64,
67        reason: String,
68        changed_at: chrono::DateTime<chrono::Utc>,
69    },
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(untagged)]
74enum ChangeLogEntry {
75    Single(ConfessionChange),
76    Batch { changes: Vec<ConfessionChange> },
77}
78
79impl ConfessionChange {
80    fn id(&self) -> &MemoryId {
81        match self {
82            Self::Revised { id, .. } | Self::Organized { id, .. } | Self::Archived { id, .. } => id,
83        }
84    }
85
86    fn base_revision(&self) -> u64 {
87        match self {
88            Self::Revised { base_revision, .. }
89            | Self::Organized { base_revision, .. }
90            | Self::Archived { base_revision, .. } => *base_revision,
91        }
92    }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ConfessionView {
97    pub confession: Confession,
98    pub revision: u64,
99    pub category: Option<String>,
100    pub related_ids: Vec<MemoryId>,
101    pub archived: bool,
102}
103
104impl Confession {
105    fn md_slug(&self) -> String {
106        let date = self.created_at.format("%Y-%m-%d");
107        let slug: String = self
108            .trigger
109            .chars()
110            .filter(|c| c.is_alphanumeric() || *c == '-')
111            .take(48)
112            .collect::<String>()
113            .to_lowercase();
114        let slug = if slug.is_empty() {
115            "trigger".into()
116        } else {
117            slug
118        };
119        format!("{date}-{slug}-{}.md", &self.id.to_string()[..8])
120    }
121
122    fn render_md(&self) -> String {
123        format!(
124            "# {trigger}\n\n\
125             - **id**: `{id}`\n\
126             - **rule_violated**: {rule}\n\
127             - **created_at**: {ts}\n\n\
128             ## What I did\n\n{what}\n\n\
129             ## Why\n\n{why}\n\n\
130             ## Mitigation\n\n{mit}\n",
131            trigger = self.trigger,
132            id = self.id,
133            rule = self.rule_violated,
134            ts = self.created_at.to_rfc3339(),
135            what = self.what_i_did,
136            why = self.why,
137            mit = self.mitigation,
138        )
139    }
140}
141
142pub struct ConfessionStore {
143    dir: PathBuf,
144    index_path: PathBuf,
145    changes_path: PathBuf,
146    anchor_index: Option<Arc<AnchorIndex>>,
147    redactor: Option<Arc<crate::redact::Redactor>>,
148}
149
150impl ConfessionStore {
151    pub fn at(scope_dir: impl AsRef<Path>) -> Self {
152        let dir = scope_dir.as_ref().to_path_buf();
153        let index_path = dir.join("confessions.jsonl");
154        let changes_path = dir.join("changes.jsonl");
155        Self {
156            dir,
157            index_path,
158            changes_path,
159            anchor_index: None,
160            redactor: None,
161        }
162    }
163
164    pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
165        self.anchor_index = Some(index);
166        self
167    }
168
169    pub fn with_redactor(mut self, redactor: Arc<crate::redact::Redactor>) -> Self {
170        self.redactor = Some(redactor);
171        self
172    }
173
174    pub async fn append(&self, confession: Confession) -> Result<MemoryId, RuntimeError> {
175        let confession = self.redact_if_needed(confession);
176        let id = confession.id.clone();
177        let dir = self.dir.clone();
178        let index_path = self.index_path.clone();
179        let for_write = confession.clone();
180        let anchor_index = self.anchor_index.clone();
181        tokio::task::spawn_blocking(move || {
182            with_store_lock(&dir, || {
183                let md_path = dir.join(for_write.md_slug());
184                std::fs::write(&md_path, for_write.render_md()).map_err(store_error)?;
185                append_line(&index_path, &for_write)?;
186                if let Some(index) = &anchor_index
187                    && let Err(error) = insert_confession(index, &for_write)
188                {
189                    crate::notify!(
190                        warn,
191                        "confession index insert failed (id={}): {error}",
192                        for_write.id
193                    );
194                }
195                Ok(())
196            })
197        })
198        .await
199        .map_err(|e| RuntimeError::ToolFailed(format!("confession writer: {e}")))??;
200        Ok(id)
201    }
202
203    fn redact_if_needed(&self, mut c: Confession) -> Confession {
204        let Some(r) = &self.redactor else {
205            return c;
206        };
207        c.trigger = r.redact(&c.trigger).0;
208        c.rule_violated = r.redact(&c.rule_violated).0;
209        c.what_i_did = r.redact(&c.what_i_did).0;
210        c.why = r.redact(&c.why).0;
211        c.mitigation = r.redact(&c.mitigation).0;
212        c
213    }
214
215    pub async fn find_by_trigger_fts(
216        &self,
217        query: &str,
218    ) -> Result<Option<Vec<Confession>>, RuntimeError> {
219        let Some(idx) = self.anchor_index.as_deref() else {
220            return Ok(None);
221        };
222        let conn = idx.conn();
223        let mut stmt = conn
224            .prepare(
225                "SELECT c.id, c.trigger, c.rule_violated, c.what_i_did, c.why, c.mitigation, c.created_at \
226                 FROM confessions c \
227                 JOIN confessions_fts f ON f.rowid = c.rowid \
228                 WHERE f.confessions_fts MATCH ? \
229                 ORDER BY c.rowid",
230            )
231            .map_err(|e| RuntimeError::ToolFailed(format!("fts prepare: {e}")))?;
232        let rows = stmt
233            .query_map(rusqlite::params![query], |row| {
234                let created_at: String = row.get(6)?;
235                let created = chrono::DateTime::parse_from_rfc3339(&created_at)
236                    .map(|d| d.with_timezone(&chrono::Utc))
237                    .unwrap_or_else(|_| chrono::Utc::now());
238                let id_str: String = row.get(0)?;
239                let id = uuid::Uuid::parse_str(&id_str)
240                    .map(MemoryId)
241                    .unwrap_or_else(|_| MemoryId::now());
242                Ok(Confession {
243                    id,
244                    trigger: row.get(1)?,
245                    rule_violated: row.get(2)?,
246                    what_i_did: row.get(3)?,
247                    why: row.get(4)?,
248                    mitigation: row.get(5)?,
249                    anchors: Vec::new(),
250                    created_at: created,
251                })
252            })
253            .map_err(|e| RuntimeError::ToolFailed(format!("fts query: {e}")))?;
254        let mut out = Vec::new();
255        for r in rows {
256            match r {
257                Ok(c) => out.push(c),
258                Err(e) => return Err(RuntimeError::ToolFailed(format!("fts row: {e}"))),
259            }
260        }
261        Ok(Some(out))
262    }
263
264    pub async fn list(&self) -> Result<Vec<Confession>, RuntimeError> {
265        Ok(self
266            .list_with_meta(false)
267            .await?
268            .into_iter()
269            .map(|view| view.confession)
270            .collect())
271    }
272
273    pub async fn list_with_meta(
274        &self,
275        include_archived: bool,
276    ) -> Result<Vec<ConfessionView>, RuntimeError> {
277        let dir = self.dir.clone();
278        let index_path = self.index_path.clone();
279        let changes_path = self.changes_path.clone();
280        tokio::task::spawn_blocking(move || {
281            with_store_lock(&dir, || {
282                project_changes(
283                    read_lines(&index_path)?,
284                    read_change_lines(&changes_path)?,
285                    include_archived,
286                )
287            })
288        })
289        .await
290        .map_err(|e| RuntimeError::ToolFailed(format!("confession reader: {e}")))?
291    }
292
293    pub async fn history(&self, id: &MemoryId) -> Result<Vec<ConfessionChange>, RuntimeError> {
294        let dir = self.dir.clone();
295        let changes_path = self.changes_path.clone();
296        let id = id.clone();
297        tokio::task::spawn_blocking(move || {
298            with_store_lock(&dir, || {
299                Ok(read_change_lines(&changes_path)?
300                    .into_iter()
301                    .filter(|change| change.id() == &id)
302                    .collect())
303            })
304        })
305        .await
306        .map_err(|e| RuntimeError::ToolFailed(format!("confession reader: {e}")))?
307    }
308
309    pub async fn revise(
310        &self,
311        id: MemoryId,
312        base_revision: u64,
313        mut fields: ConfessionFields,
314    ) -> Result<ConfessionView, RuntimeError> {
315        if let Some(redactor) = &self.redactor {
316            fields.trigger = redactor.redact(&fields.trigger).0;
317            fields.rule_violated = redactor.redact(&fields.rule_violated).0;
318            fields.what_i_did = redactor.redact(&fields.what_i_did).0;
319            fields.why = redactor.redact(&fields.why).0;
320            fields.mitigation = redactor.redact(&fields.mitigation).0;
321        }
322        let change = ConfessionChange::Revised {
323            id: id.clone(),
324            base_revision,
325            fields,
326            changed_at: chrono::Utc::now(),
327        };
328        Ok(self.append_changes(vec![change]).await?.remove(0))
329    }
330
331    pub async fn organize(
332        &self,
333        changes: Vec<ConfessionChange>,
334    ) -> Result<Vec<ConfessionView>, RuntimeError> {
335        if changes
336            .iter()
337            .any(|change| !matches!(change, ConfessionChange::Organized { .. }))
338        {
339            return Err(RuntimeError::ToolFailed(
340                "expected organization changes".into(),
341            ));
342        }
343        self.append_changes(changes).await
344    }
345
346    pub async fn archive(
347        &self,
348        id: MemoryId,
349        base_revision: u64,
350        reason: String,
351    ) -> Result<ConfessionView, RuntimeError> {
352        let change = ConfessionChange::Archived {
353            id,
354            base_revision,
355            reason,
356            changed_at: chrono::Utc::now(),
357        };
358        Ok(self.append_changes(vec![change]).await?.remove(0))
359    }
360
361    async fn append_changes(
362        &self,
363        changes: Vec<ConfessionChange>,
364    ) -> Result<Vec<ConfessionView>, RuntimeError> {
365        let dir = self.dir.clone();
366        let index_path = self.index_path.clone();
367        let changes_path = self.changes_path.clone();
368        let anchor_index = self.anchor_index.clone();
369        tokio::task::spawn_blocking(move || {
370            with_store_lock(&dir, || {
371                let originals = read_lines::<Confession>(&index_path)?;
372                let existing = read_change_lines(&changes_path)?;
373                let current = project_changes(originals.clone(), existing.clone(), true)?;
374                let by_id: HashMap<_, _> = current
375                    .iter()
376                    .map(|view| (view.confession.id.clone(), view.revision))
377                    .collect();
378                let mut seen = std::collections::HashSet::new();
379                for change in &changes {
380                    if !seen.insert(change.id().clone()) {
381                        return Err(RuntimeError::ToolFailed(
382                            "duplicate confession id in batch".into(),
383                        ));
384                    }
385                    if by_id.get(change.id()) != Some(&change.base_revision()) {
386                        return Err(RuntimeError::ToolFailed(
387                            "confession revision conflict".into(),
388                        ));
389                    }
390                    if let ConfessionChange::Organized {
391                        category,
392                        related_ids,
393                        ..
394                    } = change
395                    {
396                        if category.trim().is_empty() || category.chars().count() > 60 {
397                            return Err(RuntimeError::ToolFailed(
398                                "invalid confession category".into(),
399                            ));
400                        }
401                        if related_ids
402                            .iter()
403                            .any(|related| !by_id.contains_key(related))
404                        {
405                            return Err(RuntimeError::ToolFailed(
406                                "unknown related confession".into(),
407                            ));
408                        }
409                    }
410                }
411                append_line(
412                    &changes_path,
413                    &ChangeLogEntry::Batch {
414                        changes: changes.clone(),
415                    },
416                )?;
417                let all = project_changes(
418                    originals,
419                    existing
420                        .into_iter()
421                        .chain(changes.iter().cloned())
422                        .collect(),
423                    true,
424                )?;
425                if let Some(index) = &anchor_index {
426                    for view in all.iter().filter(|view| {
427                        changes.iter().any(|change| {
428                            change.id() == &view.confession.id
429                                && !matches!(change, ConfessionChange::Organized { .. })
430                        })
431                    }) {
432                        let result = if view.archived {
433                            remove_confession_index(index, &view.confession.id)
434                        } else {
435                            insert_confession(index, &view.confession)
436                        };
437                        if let Err(error) = result {
438                            crate::notify!(
439                                warn,
440                                "confession index update failed (id={}): {error}",
441                                view.confession.id
442                            );
443                        }
444                    }
445                }
446                Ok(all
447                    .into_iter()
448                    .filter(|view| seen.contains(&view.confession.id))
449                    .collect())
450            })
451        })
452        .await
453        .map_err(|e| RuntimeError::ToolFailed(format!("confession writer: {e}")))?
454    }
455
456    pub async fn find_by_trigger(&self, needle: &str) -> Result<Vec<Confession>, RuntimeError> {
457        if tokio::fs::try_exists(&self.changes_path)
458            .await
459            .unwrap_or(false)
460        {
461            let needle = needle.to_lowercase();
462            return Ok(self
463                .list()
464                .await?
465                .into_iter()
466                .filter(|c| {
467                    [
468                        &c.trigger,
469                        &c.rule_violated,
470                        &c.what_i_did,
471                        &c.why,
472                        &c.mitigation,
473                    ]
474                    .iter()
475                    .any(|field| field.to_lowercase().contains(&needle))
476                })
477                .collect());
478        }
479        if let Ok(Some(hits)) = self.find_by_trigger_fts(needle).await
480            && !hits.is_empty()
481        {
482            return Ok(hits);
483        }
484        let all = self.list().await?;
485        Ok(all
486            .into_iter()
487            .filter(|c| c.trigger.contains(needle))
488            .collect())
489    }
490
491    pub fn dir(&self) -> &Path {
492        &self.dir
493    }
494
495    pub fn index_path(&self) -> &Path {
496        &self.index_path
497    }
498}
499
500fn store_error(error: std::io::Error) -> RuntimeError {
501    RuntimeError::ToolFailed(format!("confession storage: {error}"))
502}
503
504fn with_store_lock<T>(
505    dir: &Path,
506    operation: impl FnOnce() -> Result<T, RuntimeError>,
507) -> Result<T, RuntimeError> {
508    std::fs::create_dir_all(dir).map_err(store_error)?;
509    let lock = std::fs::OpenOptions::new()
510        .create(true)
511        .write(true)
512        .truncate(false)
513        .open(dir.join(".confessions.lock"))
514        .map_err(store_error)?;
515    fs2::FileExt::lock_exclusive(&lock).map_err(store_error)?;
516    operation()
517}
518
519fn append_line(path: &Path, value: &impl Serialize) -> Result<(), RuntimeError> {
520    append_lines(path, std::slice::from_ref(value))
521}
522
523fn append_lines(path: &Path, values: &[impl Serialize]) -> Result<(), RuntimeError> {
524    let mut file = std::fs::OpenOptions::new()
525        .create(true)
526        .append(true)
527        .open(path)
528        .map_err(store_error)?;
529    let mut output = Vec::new();
530    for value in values {
531        let mut line = serde_json::to_vec(value)
532            .map_err(|e| RuntimeError::ToolFailed(format!("encode confession: {e}")))?;
533        line.push(b'\n');
534        output.extend(line);
535    }
536    file.write_all(&output).map_err(store_error)?;
537    file.flush().map_err(store_error)
538}
539
540fn read_change_lines(path: &Path) -> Result<Vec<ConfessionChange>, RuntimeError> {
541    Ok(flatten_changes(read_lines::<ChangeLogEntry>(path)?))
542}
543
544fn flatten_changes(entries: Vec<ChangeLogEntry>) -> Vec<ConfessionChange> {
545    entries
546        .into_iter()
547        .flat_map(|entry| match entry {
548            ChangeLogEntry::Single(change) => vec![change],
549            ChangeLogEntry::Batch { changes } => changes,
550        })
551        .collect()
552}
553
554fn read_lines<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Vec<T>, RuntimeError> {
555    let text = match std::fs::read_to_string(path) {
556        Ok(text) => text,
557        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
558        Err(error) => return Err(store_error(error)),
559    };
560    text.lines()
561        .filter(|line| !line.trim().is_empty())
562        .map(|line| {
563            serde_json::from_str(line)
564                .map_err(|error| RuntimeError::ToolFailed(format!("decode confession: {error}")))
565        })
566        .collect()
567}
568
569fn project_changes(
570    originals: Vec<Confession>,
571    changes: Vec<ConfessionChange>,
572    include_archived: bool,
573) -> Result<Vec<ConfessionView>, RuntimeError> {
574    let mut views = originals
575        .into_iter()
576        .map(|confession| ConfessionView {
577            confession,
578            revision: 0,
579            category: None,
580            related_ids: Vec::new(),
581            archived: false,
582        })
583        .collect::<Vec<_>>();
584    let positions = views
585        .iter()
586        .enumerate()
587        .map(|(index, view)| (view.confession.id.clone(), index))
588        .collect::<HashMap<_, _>>();
589    for change in changes {
590        let position = *positions.get(change.id()).ok_or_else(|| {
591            RuntimeError::ToolFailed("confession change refers to missing id".into())
592        })?;
593        let view = &mut views[position];
594        if view.revision != change.base_revision() {
595            return Err(RuntimeError::ToolFailed(
596                "confession change revision mismatch".into(),
597            ));
598        }
599        match change {
600            ConfessionChange::Revised { fields, .. } => {
601                view.confession.trigger = fields.trigger;
602                view.confession.rule_violated = fields.rule_violated;
603                view.confession.what_i_did = fields.what_i_did;
604                view.confession.why = fields.why;
605                view.confession.mitigation = fields.mitigation;
606            }
607            ConfessionChange::Organized {
608                category,
609                related_ids,
610                ..
611            } => {
612                view.category = Some(category);
613                view.related_ids = related_ids;
614            }
615            ConfessionChange::Archived { .. } => view.archived = true,
616        }
617        view.revision += 1;
618    }
619    if !include_archived {
620        views.retain(|view| !view.archived);
621    }
622    Ok(views)
623}
624
625fn remove_confession_index(index: &AnchorIndex, id: &MemoryId) -> rusqlite::Result<()> {
626    let mut conn = index.conn();
627    let tx = conn.transaction()?;
628    let rowid = tx
629        .query_row(
630            "SELECT rowid FROM confessions WHERE id = ?",
631            rusqlite::params![id.to_string()],
632            |row| row.get::<_, i64>(0),
633        )
634        .optional()?;
635    if let Some(rowid) = rowid {
636        tx.execute(
637            "DELETE FROM confessions_fts WHERE rowid = ?",
638            rusqlite::params![rowid],
639        )?;
640        tx.execute(
641            "DELETE FROM confessions WHERE id = ?",
642            rusqlite::params![id.to_string()],
643        )?;
644        tx.execute(
645            "DELETE FROM anchors WHERE subject_kind = 'confession' AND subject_id = ?",
646            rusqlite::params![id.to_string()],
647        )?;
648    }
649    tx.commit()
650}
651
652fn insert_confession(index: &AnchorIndex, c: &Confession) -> rusqlite::Result<()> {
653    let mut conn = index.conn();
654    let tx = conn.transaction()?;
655    let old_rowid = tx
656        .query_row(
657            "SELECT rowid FROM confessions WHERE id = ?",
658            rusqlite::params![c.id.to_string()],
659            |row| row.get::<_, i64>(0),
660        )
661        .optional()?;
662    if let Some(rowid) = old_rowid {
663        tx.execute(
664            "DELETE FROM confessions_fts WHERE rowid = ?",
665            rusqlite::params![rowid],
666        )?;
667    }
668    let body = c.render_md();
669    tx.execute(
670        "INSERT OR REPLACE INTO confessions \
671           (id, trigger, rule_violated, what_i_did, why, mitigation, body, created_at) \
672         VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
673        rusqlite::params![
674            c.id.to_string(),
675            c.trigger,
676            c.rule_violated,
677            c.what_i_did,
678            c.why,
679            c.mitigation,
680            body,
681            c.created_at.to_rfc3339(),
682        ],
683    )?;
684    let rowid: i64 = tx.last_insert_rowid();
685    tx.execute(
686        "INSERT OR REPLACE INTO confessions_fts \
687           (rowid, trigger, rule_violated, what_i_did, why, mitigation, body) \
688         VALUES (?, ?, ?, ?, ?, ?, ?)",
689        rusqlite::params![
690            rowid,
691            c.trigger,
692            c.rule_violated,
693            c.what_i_did,
694            c.why,
695            c.mitigation,
696            body,
697        ],
698    )?;
699    tx.execute(
700        "DELETE FROM anchors WHERE subject_kind = 'confession' AND subject_id = ?",
701        rusqlite::params![c.id.to_string()],
702    )?;
703    for anchor in &c.anchors {
704        if let Some((kind, r)) = anchor.split_once(':') {
705            tx.execute(
706                "INSERT INTO anchors (kind, ref, subject_kind, subject_id, session_id, created_at) \
707                 VALUES (?, ?, 'confession', ?, NULL, ?)",
708                rusqlite::params![kind, r, c.id.to_string(), c.created_at.to_rfc3339()],
709            )?;
710        }
711    }
712    tx.commit()
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use tempfile::TempDir;
719
720    fn sample(trigger: &str, rule: &str) -> Confession {
721        Confession {
722            id: MemoryId::now(),
723            trigger: trigger.into(),
724            rule_violated: rule.into(),
725            what_i_did: "wrote `as any`".into(),
726            why: "was in a hurry".into(),
727            mitigation: "run cargo check on every edit".into(),
728            anchors: vec![],
729            created_at: chrono::Utc::now(),
730        }
731    }
732
733    #[tokio::test]
734    async fn append_then_list_returns_confession() {
735        let dir = TempDir::new().unwrap();
736        let store = ConfessionStore::at(dir.path());
737        let id = store
738            .append(sample("you keep doing X", "no-as-any"))
739            .await
740            .unwrap();
741        let items = store.list().await.unwrap();
742        assert_eq!(items.len(), 1);
743        assert_eq!(items[0].id, id);
744    }
745
746    #[tokio::test]
747    async fn find_by_trigger_filters() {
748        let dir = TempDir::new().unwrap();
749        let store = ConfessionStore::at(dir.path());
750        store
751            .append(sample("comment discipline", "no-narrative-comments"))
752            .await
753            .unwrap();
754        store
755            .append(sample("type safety", "no-as-any"))
756            .await
757            .unwrap();
758        let hits = store.find_by_trigger("comment").await.unwrap();
759        assert_eq!(hits.len(), 1);
760        assert_eq!(hits[0].rule_violated, "no-narrative-comments");
761    }
762
763    #[tokio::test]
764    async fn empty_returns_empty() {
765        let dir = TempDir::new().unwrap();
766        let store = ConfessionStore::at(dir.path());
767        assert!(store.list().await.unwrap().is_empty());
768    }
769
770    #[tokio::test]
771    async fn append_with_index_populates_confessions_and_fts() {
772        let dir = TempDir::new().unwrap();
773        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
774        let store = ConfessionStore::at(dir.path()).with_index(index.clone());
775        let mut c = sample("comment discipline yet again", "no-narrative-comments");
776        c.anchors = vec!["flow_run:00000000-0000-0000-0000-000000000001".into()];
777        store.append(c).await.unwrap();
778
779        let conn = index.conn();
780        let count: i64 = conn
781            .query_row(
782                "SELECT COUNT(*) FROM confessions",
783                rusqlite::params![],
784                |r| r.get(0),
785            )
786            .unwrap();
787        assert_eq!(count, 1);
788
789        let fts_hit: i64 = conn
790            .query_row(
791                "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH ?",
792                rusqlite::params!["narrative"],
793                |r| r.get(0),
794            )
795            .unwrap();
796        assert_eq!(fts_hit, 1, "fts should find `narrative` in rule_violated");
797
798        let anchor_count: i64 = conn
799            .query_row(
800                "SELECT COUNT(*) FROM anchors WHERE kind='flow_run'",
801                rusqlite::params![],
802                |r| r.get(0),
803            )
804            .unwrap();
805        assert_eq!(anchor_count, 1);
806    }
807
808    #[tokio::test]
809    async fn revision_preserves_history_and_changes_agent_search() {
810        let dir = TempDir::new().unwrap();
811        let store = ConfessionStore::at(dir.path());
812        let original = sample("old trigger", "old rule");
813        let id = original.id.clone();
814        store.append(original.clone()).await.unwrap();
815        let mut fields = ConfessionFields::from(&original);
816        fields.trigger = "new trigger".into();
817        fields.mitigation = "new mitigation".into();
818        let revised = store.revise(id.clone(), 0, fields).await.unwrap();
819        assert_eq!(revised.revision, 1);
820        assert_eq!(store.list().await.unwrap()[0].trigger, "new trigger");
821        assert_eq!(store.find_by_trigger("old trigger").await.unwrap().len(), 0);
822        assert_eq!(store.find_by_trigger("new trigger").await.unwrap().len(), 1);
823        assert_eq!(store.history(&id).await.unwrap().len(), 1);
824        let raw: Vec<Confession> = read_jsonl(&store.index_path).await.unwrap();
825        assert_eq!(raw[0].trigger, "old trigger");
826        assert!(
827            store
828                .revise(id, 0, ConfessionFields::from(&original))
829                .await
830                .is_err()
831        );
832    }
833
834    #[tokio::test]
835    async fn organization_batch_rejects_conflict_without_partial_write() {
836        let dir = TempDir::new().unwrap();
837        let store = ConfessionStore::at(dir.path());
838        let first = sample("first", "rule");
839        let second = sample("second", "rule");
840        store.append(first.clone()).await.unwrap();
841        store.append(second.clone()).await.unwrap();
842        let make = |id, base_revision| ConfessionChange::Organized {
843            id,
844            base_revision,
845            category: "group".into(),
846            related_ids: Vec::new(),
847            changed_at: chrono::Utc::now(),
848        };
849        let result = store
850            .organize(vec![make(first.id.clone(), 0), make(second.id.clone(), 1)])
851            .await;
852        assert!(result.is_err());
853        assert!(store.history(&first.id).await.unwrap().is_empty());
854        store
855            .organize(vec![make(first.id.clone(), 0), make(second.id.clone(), 0)])
856            .await
857            .unwrap();
858        let rows = store.list_with_meta(false).await.unwrap();
859        assert!(
860            rows.iter()
861                .all(|view| view.category.as_deref() == Some("group"))
862        );
863        assert_eq!(
864            read_lines::<ChangeLogEntry>(&store.changes_path)
865                .unwrap()
866                .len(),
867            1
868        );
869    }
870
871    #[tokio::test]
872    async fn revision_replaces_fts_and_anchor_projection() {
873        let dir = TempDir::new().unwrap();
874        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
875        let store = ConfessionStore::at(dir.path().join("confessions")).with_index(index.clone());
876        let mut original = sample("old trigger", "rule");
877        original.anchors.push("turn:one".into());
878        store.append(original.clone()).await.unwrap();
879        let mut fields = ConfessionFields::from(&original);
880        fields.trigger = "new trigger".into();
881        store.revise(original.id.clone(), 0, fields).await.unwrap();
882        let conn = index.conn();
883        let fts_old: i64 = conn
884            .query_row(
885                "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH 'old'",
886                [],
887                |row| row.get(0),
888            )
889            .unwrap();
890        let fts_new: i64 = conn
891            .query_row(
892                "SELECT COUNT(*) FROM confessions_fts WHERE confessions_fts MATCH 'new'",
893                [],
894                |row| row.get(0),
895            )
896            .unwrap();
897        let anchors: i64 = conn
898            .query_row(
899                "SELECT COUNT(*) FROM anchors WHERE subject_kind = 'confession' AND subject_id = ?",
900                rusqlite::params![original.id.to_string()],
901                |row| row.get(0),
902            )
903            .unwrap();
904        assert_eq!((fts_old, fts_new, anchors), (0, 1, 1));
905    }
906
907    #[tokio::test]
908    async fn archived_record_remains_in_history_but_leaves_search_index() {
909        let dir = TempDir::new().unwrap();
910        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
911        let store = ConfessionStore::at(dir.path().join("confessions")).with_index(index.clone());
912        let original = sample("old trigger", "rule");
913        store.append(original.clone()).await.unwrap();
914        store
915            .archive(original.id.clone(), 0, "superseded".into())
916            .await
917            .unwrap();
918        assert!(store.list().await.unwrap().is_empty());
919        assert_eq!(store.list_with_meta(true).await.unwrap().len(), 1);
920        assert!(matches!(
921            store.history(&original.id).await.unwrap().as_slice(),
922            [ConfessionChange::Archived { reason, .. }] if reason == "superseded"
923        ));
924        let count: i64 = index
925            .conn()
926            .query_row("SELECT COUNT(*) FROM confessions", [], |row| row.get(0))
927            .unwrap();
928        assert_eq!(count, 0);
929    }
930
931    #[tokio::test]
932    async fn find_by_trigger_fts_returns_none_without_index() {
933        let dir = TempDir::new().unwrap();
934        let store = ConfessionStore::at(dir.path());
935        assert!(store.find_by_trigger_fts("x").await.unwrap().is_none());
936    }
937
938    #[tokio::test]
939    async fn find_by_trigger_fts_returns_matching_rows() {
940        let dir = TempDir::new().unwrap();
941        let index = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
942        let store = ConfessionStore::at(dir.path()).with_index(index);
943        store
944            .append(sample("boot flow crash", "no-panic-in-boot"))
945            .await
946            .unwrap();
947        store
948            .append(sample("type safety again", "no-as-any"))
949            .await
950            .unwrap();
951        let hits = store.find_by_trigger_fts("boot").await.unwrap().unwrap();
952        assert_eq!(hits.len(), 1);
953        assert_eq!(hits[0].rule_violated, "no-panic-in-boot");
954    }
955
956    #[tokio::test]
957    async fn append_writes_md_body_alongside_index() {
958        let dir = TempDir::new().unwrap();
959        let store = ConfessionStore::at(dir.path());
960        store
961            .append(sample("comment discipline again", "no-narrative-comments"))
962            .await
963            .unwrap();
964        let mut md_files = tokio::fs::read_dir(dir.path()).await.unwrap();
965        let mut found_md = false;
966        while let Some(entry) = md_files.next_entry().await.unwrap() {
967            let name = entry.file_name();
968            if name.to_string_lossy().ends_with(".md") {
969                found_md = true;
970                let body = tokio::fs::read_to_string(entry.path()).await.unwrap();
971                assert!(body.starts_with("# comment discipline again"));
972                assert!(body.contains("no-narrative-comments"));
973            }
974        }
975        assert!(found_md, "expected a `.md` body file next to the index");
976    }
977}