Skip to main content

atman_runtime/memory/
spec.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use super::MemoryId;
7use crate::error::RuntimeError;
8use crate::index::AnchorIndex;
9
10const PHASES: &[&str] = &[
11    "research",
12    "design",
13    "implementation",
14    "testing",
15    "retrospective",
16];
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct SpecEntry {
20    pub id: MemoryId,
21    pub feature: String,
22    pub phase: String,
23    pub content: String,
24    pub ts: chrono::DateTime<chrono::Utc>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct SpecDeviation {
29    pub id: MemoryId,
30    pub feature: String,
31    pub section: String,
32    pub delta: String,
33    pub reason: String,
34    pub ts: chrono::DateTime<chrono::Utc>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct SpecReview {
39    pub feature: String,
40    pub design_revision: String,
41    pub approved: bool,
42    pub ts: chrono::DateTime<chrono::Utc>,
43}
44
45#[derive(Clone)]
46pub struct SpecStore {
47    root: PathBuf,
48    anchor_index: Option<Arc<AnchorIndex>>,
49}
50
51impl SpecStore {
52    pub fn new(root: impl Into<PathBuf>) -> Self {
53        Self {
54            root: root.into(),
55            anchor_index: None,
56        }
57    }
58
59    pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
60        self.anchor_index = Some(index);
61        self
62    }
63
64    fn feature_dir(&self, feature: &str) -> PathBuf {
65        self.root.join(feature)
66    }
67
68    fn entries_path(&self, feature: &str) -> PathBuf {
69        self.feature_dir(feature).join("entries.jsonl")
70    }
71
72    fn deviations_path(&self, feature: &str) -> PathBuf {
73        self.feature_dir(feature).join("deviations.jsonl")
74    }
75
76    fn reviews_path(&self, feature: &str) -> PathBuf {
77        self.feature_dir(feature).join("reviews.jsonl")
78    }
79
80    pub async fn design_revision(&self, feature: &str) -> Result<Option<String>, RuntimeError> {
81        let entries = self.entries(feature).await?;
82        if !entries.iter().any(|entry| entry.phase == "design") {
83            return Ok(None);
84        }
85        Ok(Some(revision_for(&render_phase_markdown(
86            feature, "design", &entries,
87        ))))
88    }
89
90    pub async fn phase_markdown(&self, feature: &str, phase: &str) -> Result<String, RuntimeError> {
91        validate_feature(feature)?;
92        if !PHASES.contains(&phase) {
93            return Err(RuntimeError::ToolFailed(format!(
94                "spec.read: unknown phase `{phase}`"
95            )));
96        }
97        let entries = self.entries(feature).await?;
98        Ok(render_phase_markdown(feature, phase, &entries))
99    }
100
101    pub async fn phase_revision(
102        &self,
103        feature: &str,
104        phase: &str,
105    ) -> Result<Option<String>, RuntimeError> {
106        let entries = self.entries(feature).await?;
107        if !entries.iter().any(|entry| entry.phase == phase) {
108            return Ok(None);
109        }
110        Ok(Some(revision_for(
111            &self.phase_markdown(feature, phase).await?,
112        )))
113    }
114
115    pub async fn materialized_revision(
116        &self,
117        feature: &str,
118        phase: &str,
119    ) -> Result<String, RuntimeError> {
120        validate_feature(feature)?;
121        if !PHASES.contains(&phase) {
122            return Err(RuntimeError::ToolFailed(format!(
123                "spec.read: unknown phase `{phase}`"
124            )));
125        }
126        let filename = if phase == "implementation" {
127            "IMPLEMENTATION.md".to_owned()
128        } else {
129            format!("{phase}.md")
130        };
131        let path = self.feature_dir(feature).join(filename);
132        match tokio::fs::read_to_string(path).await {
133            Ok(content) => Ok(revision_for(&content)),
134            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
135            Err(error) => Err(RuntimeError::ToolFailed(format!(
136                "spec.read: materialized file: {error}"
137            ))),
138        }
139    }
140
141    pub async fn review(
142        &self,
143        feature: &str,
144        design_revision: &str,
145        approved: bool,
146    ) -> Result<SpecReview, RuntimeError> {
147        if self.design_revision(feature).await?.as_deref() != Some(design_revision) {
148            return Err(RuntimeError::ToolFailed(
149                "spec.review: design revision is missing or stale".into(),
150            ));
151        }
152        let file_revision = self.materialized_revision(feature, "design").await?;
153        if file_revision != design_revision {
154            return Err(RuntimeError::ToolFailed(
155                "spec.review: materialized design differs from the current revision".into(),
156            ));
157        }
158        let review = SpecReview {
159            feature: feature.into(),
160            design_revision: design_revision.into(),
161            approved,
162            ts: chrono::Utc::now(),
163        };
164        super::append_jsonl(&self.reviews_path(feature), &review).await?;
165        Ok(review)
166    }
167
168    pub async fn status(&self, feature: &str) -> Result<SpecStatus, RuntimeError> {
169        validate_feature(feature)?;
170        let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
171        if entries.is_empty() {
172            return Ok(SpecStatus {
173                feature: feature.into(),
174                phase: "not_started".into(),
175                entry_count: 0,
176                deviation_count: 0,
177                design_revision: None,
178                approved_design_revision: None,
179            });
180        }
181        let latest = latest_phase(&entries);
182        let dev_count = super::read_jsonl::<SpecDeviation>(&self.deviations_path(feature))
183            .await?
184            .len();
185        let design_revision = entries
186            .iter()
187            .any(|entry| entry.phase == "design")
188            .then(|| revision_for(&render_phase_markdown(feature, "design", &entries)));
189        let reviews: Vec<SpecReview> = super::read_jsonl(&self.reviews_path(feature)).await?;
190        let materialized_revision = self.materialized_revision(feature, "design").await?;
191        let approved_design_revision =
192            reviews
193                .last()
194                .filter(|review| review.approved)
195                .and_then(|review| {
196                    (design_revision.as_deref() == Some(review.design_revision.as_str())
197                        && materialized_revision == review.design_revision)
198                        .then(|| review.design_revision.clone())
199                });
200        Ok(SpecStatus {
201            feature: feature.into(),
202            phase: latest,
203            entry_count: entries.len(),
204            deviation_count: dev_count,
205            design_revision,
206            approved_design_revision,
207        })
208    }
209
210    pub async fn update(
211        &self,
212        feature: &str,
213        phase: &str,
214        content: String,
215    ) -> Result<SpecEntry, RuntimeError> {
216        if !PHASES.contains(&phase) {
217            return Err(RuntimeError::ToolFailed(format!(
218                "spec.update: unknown phase `{phase}` (want one of {})",
219                PHASES.join(", ")
220            )));
221        }
222        let current = self.status(feature).await?;
223        if let Err(msg) = check_phase_transition(&current.phase, phase) {
224            return Err(RuntimeError::ToolFailed(format!("spec.update: {msg}")));
225        }
226        let entry = SpecEntry {
227            id: MemoryId::now(),
228            feature: feature.into(),
229            phase: phase.into(),
230            content,
231            ts: chrono::Utc::now(),
232        };
233        super::append_jsonl(&self.entries_path(feature), &entry).await?;
234        if let Some(idx) = &self.anchor_index
235            && let Err(e) = insert_entry(idx, &entry)
236        {
237            let key = format!("spec.index.entry:{}", entry.id);
238            crate::notify!(
239                warn,
240                location = Inline,
241                stack = dedupe(key, 60_000),
242                "spec entry index insert failed (id={}): {e}",
243                entry.id
244            );
245        }
246        Ok(entry)
247    }
248
249    pub async fn deviate(
250        &self,
251        feature: &str,
252        section: String,
253        delta: String,
254        reason: String,
255    ) -> Result<SpecDeviation, RuntimeError> {
256        let current = self.status(feature).await?;
257        if current.phase == "not_started" {
258            return Err(RuntimeError::ToolFailed(
259                "spec.deviate: feature has no entries yet, run spec.update first".into(),
260            ));
261        }
262        let dev = SpecDeviation {
263            id: MemoryId::now(),
264            feature: feature.into(),
265            section,
266            delta,
267            reason,
268            ts: chrono::Utc::now(),
269        };
270        super::append_jsonl(&self.deviations_path(feature), &dev).await?;
271        if let Some(idx) = &self.anchor_index
272            && let Err(e) = insert_deviation(idx, &dev)
273        {
274            let key = format!("spec.index.deviation:{}", dev.id);
275            crate::notify!(
276                warn,
277                location = Inline,
278                stack = dedupe(key, 60_000),
279                "spec deviation index insert failed (id={}): {e}",
280                dev.id
281            );
282        }
283        Ok(dev)
284    }
285
286    pub async fn deviations(&self, feature: &str) -> Result<Vec<SpecDeviation>, RuntimeError> {
287        validate_feature(feature)?;
288        super::read_jsonl(&self.deviations_path(feature)).await
289    }
290
291    pub async fn entries(&self, feature: &str) -> Result<Vec<SpecEntry>, RuntimeError> {
292        validate_feature(feature)?;
293        super::read_jsonl(&self.entries_path(feature)).await
294    }
295}
296
297fn insert_entry(index: &AnchorIndex, entry: &SpecEntry) -> rusqlite::Result<()> {
298    let conn = index.conn();
299    conn.execute(
300        "INSERT OR REPLACE INTO spec_entries (id, feature, phase, content, ts) VALUES (?, ?, ?, ?, ?)",
301        rusqlite::params![
302            entry.id.to_string(),
303            entry.feature,
304            entry.phase,
305            entry.content,
306            entry.ts.to_rfc3339(),
307        ],
308    )?;
309    let rowid = conn.last_insert_rowid();
310    conn.execute(
311        "INSERT OR REPLACE INTO spec_entries_fts (rowid, content) VALUES (?, ?)",
312        rusqlite::params![rowid, entry.content],
313    )?;
314    Ok(())
315}
316
317fn insert_deviation(index: &AnchorIndex, dev: &SpecDeviation) -> rusqlite::Result<()> {
318    let conn = index.conn();
319    conn.execute(
320        "INSERT OR REPLACE INTO spec_deviations (id, feature, section, delta, reason, ts) VALUES (?, ?, ?, ?, ?, ?)",
321        rusqlite::params![
322            dev.id.to_string(),
323            dev.feature,
324            dev.section,
325            dev.delta,
326            dev.reason,
327            dev.ts.to_rfc3339(),
328        ],
329    )?;
330    let rowid = conn.last_insert_rowid();
331    conn.execute(
332        "INSERT OR REPLACE INTO spec_deviations_fts (rowid, delta, reason) VALUES (?, ?, ?)",
333        rusqlite::params![rowid, dev.delta, dev.reason],
334    )?;
335    Ok(())
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339pub struct SpecStatus {
340    pub feature: String,
341    pub phase: String,
342    pub entry_count: usize,
343    pub deviation_count: usize,
344    pub design_revision: Option<String>,
345    pub approved_design_revision: Option<String>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct SpecMaterializeResult {
350    pub path: PathBuf,
351    pub revision: String,
352    pub changed: bool,
353}
354
355impl SpecStore {
356    pub async fn materialize(
357        &self,
358        feature: &str,
359        expected_revision: Option<&str>,
360    ) -> Result<SpecMaterializeResult, RuntimeError> {
361        self.materialize_phase(feature, None, expected_revision)
362            .await
363    }
364
365    pub async fn materialize_phase(
366        &self,
367        feature: &str,
368        phase: Option<&str>,
369        expected_revision: Option<&str>,
370    ) -> Result<SpecMaterializeResult, RuntimeError> {
371        validate_feature(feature)?;
372        if let Some(phase) = phase
373            && !PHASES.contains(&phase)
374        {
375            return Err(RuntimeError::ToolFailed(format!(
376                "spec.materialize: unknown phase `{phase}`"
377            )));
378        }
379        let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
380        let deviations: Vec<SpecDeviation> =
381            super::read_jsonl(&self.deviations_path(feature)).await?;
382        let markdown = match phase {
383            Some(phase) if phase != "implementation" => {
384                render_phase_markdown(feature, phase, &entries)
385            }
386            _ => render_materialized_markdown(feature, &entries, &deviations),
387        };
388        let revision = revision_for(&markdown);
389        let filename = match phase {
390            Some(phase) if phase != "implementation" => format!("{phase}.md"),
391            _ => "IMPLEMENTATION.md".into(),
392        };
393        let path = self.feature_dir(feature).join(filename);
394        let existing = tokio::fs::read_to_string(&path).await.ok();
395        if let Some(expected) = expected_revision
396            && existing
397                .as_deref()
398                .is_some_and(|text| revision_for(text) != expected)
399        {
400            return Err(RuntimeError::ToolFailed(format!(
401                "spec.materialize: revision conflict (expected {expected})"
402            )));
403        }
404        if existing.as_deref() == Some(markdown.as_str()) {
405            return Ok(SpecMaterializeResult {
406                path,
407                revision,
408                changed: false,
409            });
410        }
411        tokio::fs::create_dir_all(self.feature_dir(feature))
412            .await
413            .map_err(|e| {
414                RuntimeError::ToolFailed(format!("spec.materialize: create feature dir: {e}"))
415            })?;
416        let tmp = path.with_extension("md.tmp");
417        tokio::fs::write(&tmp, markdown.as_bytes())
418            .await
419            .map_err(|e| {
420                RuntimeError::ToolFailed(format!("spec.materialize: write temp file: {e}"))
421            })?;
422        tokio::fs::rename(&tmp, &path).await.map_err(|e| {
423            RuntimeError::ToolFailed(format!("spec.materialize: replace file: {e}"))
424        })?;
425        Ok(SpecMaterializeResult {
426            path,
427            revision,
428            changed: true,
429        })
430    }
431}
432
433fn validate_feature(feature: &str) -> Result<(), RuntimeError> {
434    if feature.is_empty()
435        || feature == "."
436        || feature == ".."
437        || feature.contains('/')
438        || feature.contains('\\')
439        || feature.chars().any(char::is_control)
440    {
441        return Err(RuntimeError::ToolFailed(
442            "spec feature must be a single directory name".into(),
443        ));
444    }
445    Ok(())
446}
447
448fn render_phase_markdown(feature: &str, phase: &str, entries: &[SpecEntry]) -> String {
449    let mut out = format!("# {phase} — {feature}\n\n");
450    for entry in entries.iter().filter(|entry| entry.phase == phase) {
451        out.push_str(&format!("## {}\n\n{}\n\n", entry.id.0, entry.content));
452    }
453    out
454}
455
456fn revision_for(text: &str) -> String {
457    use sha2::{Digest, Sha256};
458    let digest = Sha256::digest(text.as_bytes());
459    digest[..8]
460        .iter()
461        .map(|byte| format!("{byte:02x}"))
462        .collect()
463}
464
465fn render_materialized_markdown(
466    feature: &str,
467    entries: &[SpecEntry],
468    deviations: &[SpecDeviation],
469) -> String {
470    let mut out = format!("# Implementation — {feature}\n\n");
471    for entry in entries {
472        out.push_str(&format!(
473            "## {} — {}\n\n{}\n\n",
474            entry.phase, entry.id.0, entry.content
475        ));
476    }
477    if !deviations.is_empty() {
478        out.push_str("## Deviations\n\n");
479        for deviation in deviations {
480            out.push_str(&format!(
481                "- **{}**: {} — {}\n",
482                deviation.section, deviation.delta, deviation.reason
483            ));
484        }
485    }
486    out
487}
488
489fn latest_phase(entries: &[SpecEntry]) -> String {
490    let mut best = 0usize;
491    for e in entries {
492        if let Some(idx) = PHASES.iter().position(|p| *p == e.phase.as_str())
493            && idx + 1 > best
494        {
495            best = idx + 1;
496        }
497    }
498    if best == 0 {
499        "not_started".into()
500    } else {
501        PHASES[best - 1].into()
502    }
503}
504
505fn check_phase_transition(current: &str, next: &str) -> Result<(), String> {
506    let cur_idx = PHASES.iter().position(|p| *p == current).unwrap_or(0);
507    let next_idx = PHASES
508        .iter()
509        .position(|p| *p == next)
510        .ok_or_else(|| format!("unknown phase `{next}`"))?;
511    let is_first = current == "not_started";
512    if is_first && next != PHASES[0] {
513        return Err(format!(
514            "phase gate: must start with `{}`, not `{next}`",
515            PHASES[0]
516        ));
517    }
518    if !is_first && next_idx > cur_idx + 1 {
519        return Err(format!(
520            "phase gate: cannot skip from `{current}` to `{next}` (must go through {})",
521            PHASES[cur_idx + 1]
522        ));
523    }
524    Ok(())
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    async fn store() -> (SpecStore, tempfile::TempDir) {
532        let dir = tempfile::tempdir().unwrap();
533        let store = SpecStore::new(dir.path().to_path_buf());
534        (store, dir)
535    }
536
537    #[tokio::test]
538    async fn new_feature_status_is_not_started() {
539        let (s, _dir) = store().await;
540        let st = s.status("x").await.unwrap();
541        assert_eq!(st.phase, "not_started");
542        assert_eq!(st.entry_count, 0);
543    }
544
545    #[tokio::test]
546    async fn materialize_is_idempotent_and_detects_revision_conflicts() {
547        let (s, dir) = store().await;
548        s.update("x", "research", "notes".into()).await.unwrap();
549        let first = s.materialize("x", None).await.unwrap();
550        assert!(first.changed);
551        assert!(first.path.exists());
552        let second = s.materialize("x", Some(&first.revision)).await.unwrap();
553        assert!(!second.changed);
554        assert_eq!(first.revision, second.revision);
555        tokio::fs::write(&first.path, "user edit\n").await.unwrap();
556        let error = s.materialize("x", Some(&first.revision)).await.unwrap_err();
557        assert!(error.to_string().contains("revision conflict"));
558        assert_eq!(
559            tokio::fs::read_to_string(dir.path().join("x/IMPLEMENTATION.md"))
560                .await
561                .unwrap(),
562            "user edit\n"
563        );
564    }
565
566    #[tokio::test]
567    async fn materialize_phase_keeps_phase_documents_in_the_store() {
568        let (s, dir) = store().await;
569        s.update("x", "research", "Observed behavior".into())
570            .await
571            .unwrap();
572        s.update("x", "design", "Chosen design".into())
573            .await
574            .unwrap();
575        let research = s
576            .materialize_phase("x", Some("research"), None)
577            .await
578            .unwrap();
579        let design = s
580            .materialize_phase("x", Some("design"), None)
581            .await
582            .unwrap();
583        assert_eq!(research.path, dir.path().join("x/research.md"));
584        assert_eq!(design.path, dir.path().join("x/design.md"));
585        assert!(
586            tokio::fs::read_to_string(&research.path)
587                .await
588                .unwrap()
589                .contains("Observed behavior")
590        );
591        assert!(
592            !tokio::fs::read_to_string(&research.path)
593                .await
594                .unwrap()
595                .contains("Chosen design")
596        );
597        assert!(
598            tokio::fs::read_to_string(&design.path)
599                .await
600                .unwrap()
601                .contains("Chosen design")
602        );
603        assert_eq!(
604            s.materialize_phase("x", Some("implementation"), None)
605                .await
606                .unwrap()
607                .path,
608            dir.path().join("x/IMPLEMENTATION.md")
609        );
610    }
611
612    #[tokio::test]
613    async fn feature_cannot_escape_spec_root() {
614        let (s, _dir) = store().await;
615        assert!(s.status("../outside").await.is_err());
616        assert!(s.materialize("..", None).await.is_err());
617    }
618
619    #[tokio::test]
620    async fn update_advances_phase() {
621        let (s, _dir) = store().await;
622        s.update("x", "research", "notes".into()).await.unwrap();
623        assert_eq!(s.status("x").await.unwrap().phase, "research");
624        s.update("x", "design", "spec".into()).await.unwrap();
625        assert_eq!(s.status("x").await.unwrap().phase, "design");
626    }
627
628    #[tokio::test]
629    async fn approval_tracks_only_the_current_design_revision() {
630        let (s, dir) = store().await;
631        s.update("x", "research", "observations".into())
632            .await
633            .unwrap();
634        s.update("x", "design", "first design".into())
635            .await
636            .unwrap();
637        s.materialize_phase("x", Some("design"), Some(""))
638            .await
639            .unwrap();
640        let first = s.status("x").await.unwrap().design_revision.unwrap();
641        assert!(s.review("x", "stale", true).await.is_err());
642        s.review("x", &first, true).await.unwrap();
643        assert_eq!(
644            s.status("x").await.unwrap().approved_design_revision,
645            Some(first.clone())
646        );
647        s.update("x", "design", "changed design".into())
648            .await
649            .unwrap();
650        assert_eq!(s.status("x").await.unwrap().approved_design_revision, None);
651        s.materialize_phase("x", Some("design"), Some(&first))
652            .await
653            .unwrap();
654        let second = s.status("x").await.unwrap().design_revision.unwrap();
655        s.review("x", &second, false).await.unwrap();
656        assert_eq!(s.status("x").await.unwrap().approved_design_revision, None);
657        s.review("x", &second, true).await.unwrap();
658        assert_eq!(
659            s.status("x").await.unwrap().approved_design_revision,
660            Some(second)
661        );
662        tokio::fs::write(dir.path().join("x/design.md"), "external edit")
663            .await
664            .unwrap();
665        assert_eq!(s.status("x").await.unwrap().approved_design_revision, None);
666    }
667
668    #[tokio::test]
669    async fn phase_gate_rejects_skip() {
670        let (s, _dir) = store().await;
671        let err = s
672            .update("x", "implementation", "premature".into())
673            .await
674            .unwrap_err();
675        assert!(format!("{err}").contains("must start with `research`"));
676    }
677
678    #[tokio::test]
679    async fn phase_gate_rejects_backwards() {
680        let (s, _dir) = store().await;
681        s.update("x", "research", "r".into()).await.unwrap();
682        s.update("x", "design", "d".into()).await.unwrap();
683        let err = s
684            .update("x", "testing", "premature".into())
685            .await
686            .unwrap_err();
687        assert!(format!("{err}").contains("cannot skip"), "err: {err}");
688    }
689
690    #[tokio::test]
691    async fn deviate_requires_prior_entry() {
692        let (s, _dir) = store().await;
693        let err = s
694            .deviate("x", "sec".into(), "delta".into(), "why".into())
695            .await
696            .unwrap_err();
697        assert!(format!("{err}").contains("no entries"));
698    }
699
700    #[tokio::test]
701    async fn deviate_appends_to_deviations_file() {
702        let (s, _dir) = store().await;
703        s.update("x", "research", "r".into()).await.unwrap();
704        s.update("x", "design", "d".into()).await.unwrap();
705        s.deviate(
706            "x",
707            "data".into(),
708            "added field".into(),
709            "need array".into(),
710        )
711        .await
712        .unwrap();
713        s.deviate("x", "algo".into(), "changed loop".into(), "perf".into())
714            .await
715            .unwrap();
716        let devs = s.deviations("x").await.unwrap();
717        assert_eq!(devs.len(), 2);
718        assert_eq!(s.status("x").await.unwrap().deviation_count, 2);
719    }
720
721    #[tokio::test]
722    async fn update_and_deviate_dual_write_to_index() {
723        let dir = tempfile::tempdir().unwrap();
724        let index = std::sync::Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
725        let s = SpecStore::new(dir.path().to_path_buf()).with_index(index.clone());
726        s.update(
727            "feat_x",
728            "research",
729            "supercalifragilistic research notes".into(),
730        )
731        .await
732        .unwrap();
733        s.update(
734            "feat_x",
735            "design",
736            "midordermetamorphosis design notes".into(),
737        )
738        .await
739        .unwrap();
740        s.deviate(
741            "feat_x",
742            "sec".into(),
743            "hyperloquacious delta text".into(),
744            "quintessentialpolyphony reason text".into(),
745        )
746        .await
747        .unwrap();
748
749        let conn = index.conn();
750        let entry_count: i64 = conn
751            .query_row(
752                "SELECT COUNT(*) FROM spec_entries",
753                rusqlite::params![],
754                |r| r.get(0),
755            )
756            .unwrap();
757        let dev_count: i64 = conn
758            .query_row(
759                "SELECT COUNT(*) FROM spec_deviations",
760                rusqlite::params![],
761                |r| r.get(0),
762            )
763            .unwrap();
764        assert_eq!(entry_count, 2);
765        assert_eq!(dev_count, 1);
766
767        let entry_fts: i64 = conn
768            .query_row(
769                "SELECT COUNT(*) FROM spec_entries_fts WHERE spec_entries_fts MATCH ?",
770                rusqlite::params!["supercalifragilistic"],
771                |r| r.get(0),
772            )
773            .unwrap();
774        assert_eq!(entry_fts, 1);
775
776        let dev_fts: i64 = conn
777            .query_row(
778                "SELECT COUNT(*) FROM spec_deviations_fts WHERE spec_deviations_fts MATCH ?",
779                rusqlite::params!["quintessentialpolyphony"],
780                |r| r.get(0),
781            )
782            .unwrap();
783        assert_eq!(dev_fts, 1);
784    }
785
786    #[tokio::test]
787    async fn unknown_phase_rejected() {
788        let (s, _dir) = store().await;
789        let err = s.update("x", "brainstorm", "n".into()).await.unwrap_err();
790        assert!(format!("{err}").contains("unknown phase"));
791    }
792}