Skip to main content

aft/db/
standing_roots.rs

1//! Machine-scoped durable resolution and strict-verification state for standing roots.
2//!
3//! These rows deliberately have no harness, session, or daemon identifier: a
4//! daemon and a daemonless CLI must observe the same pinned path identity.
5
6use std::fmt;
7
8use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
9
10use crate::config::IndexKind;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct StandingRootRecord {
14    pub literal_path: String,
15    pub resolved_target: String,
16    pub resolved_git_toplevel: Option<String>,
17    pub scoped_relative_path: Option<String>,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EnsureStandingRoot {
22    Created,
23    Reloaded,
24}
25
26#[derive(Debug)]
27pub enum StandingRootError {
28    Sqlite(rusqlite::Error),
29    ResolvedPathDrift {
30        literal_path: String,
31        field: &'static str,
32        recorded: Option<String>,
33        resolved: Option<String>,
34    },
35    MissingFreshnessRow {
36        literal_path: String,
37        kind: IndexKind,
38    },
39}
40
41impl fmt::Display for StandingRootError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Sqlite(error) => write!(f, "standing roots database error: {error}"),
45            Self::ResolvedPathDrift {
46                literal_path,
47                field,
48                recorded,
49                resolved,
50            } => write!(
51                f,
52                "resolved-path-drift refusal for {literal_path:?}: {field} changed from {recorded:?} to {resolved:?}"
53            ),
54            Self::MissingFreshnessRow { literal_path, kind } => write!(
55                f,
56                "no standing freshness row exists for {literal_path:?} ({})",
57                kind.as_str()
58            ),
59        }
60    }
61}
62
63impl std::error::Error for StandingRootError {}
64
65impl From<rusqlite::Error> for StandingRootError {
66    fn from(error: rusqlite::Error) -> Self {
67        Self::Sqlite(error)
68    }
69}
70
71/// Return the recorded identity for one literal configuration spelling.
72pub fn get_standing_root(
73    conn: &Connection,
74    literal_path: &str,
75) -> Result<Option<StandingRootRecord>, StandingRootError> {
76    conn.query_row(
77        "SELECT literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
78         FROM standing_roots WHERE literal_path = ?1",
79        [literal_path],
80        |row| {
81            Ok(StandingRootRecord {
82                literal_path: row.get(0)?,
83                resolved_target: row.get(1)?,
84                resolved_git_toplevel: row.get(2)?,
85                scoped_relative_path: row.get(3)?,
86            })
87        },
88    )
89    .optional()
90    .map_err(Into::into)
91}
92
93/// Pin a new root or confirm that an unchanged literal spelling still resolves
94/// to its recorded identity. A mismatch never updates the stored record.
95pub fn ensure_standing_root(
96    conn: &mut Connection,
97    candidate: &StandingRootRecord,
98    selected_kinds: &[IndexKind],
99) -> Result<EnsureStandingRoot, StandingRootError> {
100    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
101    let existing = get_standing_root_tx(&tx, &candidate.literal_path)?;
102    let outcome = match existing {
103        Some(recorded) => {
104            ensure_same_identity(&recorded, candidate)?;
105            reconcile_freshness_rows(&tx, &candidate.literal_path, selected_kinds, false)?;
106            EnsureStandingRoot::Reloaded
107        }
108        None => {
109            tx.execute(
110                "INSERT INTO standing_roots (
111                    literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
112                 ) VALUES (?1, ?2, ?3, ?4)",
113                params![
114                    candidate.literal_path,
115                    candidate.resolved_target,
116                    candidate.resolved_git_toplevel,
117                    candidate.scoped_relative_path,
118                ],
119            )?;
120            reconcile_freshness_rows(&tx, &candidate.literal_path, selected_kinds, true)?;
121            EnsureStandingRoot::Created
122        }
123    };
124    tx.commit()?;
125    Ok(outcome)
126}
127
128/// Remove the identity and every per-kind freshness row for a deleted config
129/// entry. Artifact directories remain owned by normal generation GC.
130pub fn delete_standing_root(
131    conn: &Connection,
132    literal_path: &str,
133) -> Result<(), StandingRootError> {
134    conn.execute(
135        "DELETE FROM standing_roots WHERE literal_path = ?1",
136        [literal_path],
137    )?;
138    Ok(())
139}
140
141pub fn needs_strict_verify(
142    conn: &Connection,
143    literal_path: &str,
144    kind: IndexKind,
145) -> Result<Option<bool>, StandingRootError> {
146    conn.query_row(
147        "SELECT needs_strict_verify FROM standing_root_freshness
148         WHERE literal_path = ?1 AND index_kind = ?2",
149        params![literal_path, kind.as_str()],
150        |row| Ok(row.get::<_, i64>(0)? != 0),
151    )
152    .optional()
153    .map_err(Into::into)
154}
155
156/// Record an observation gap. A later strict verification is the only API that
157/// clears this flag.
158pub fn mark_needs_strict_verify(
159    conn: &Connection,
160    literal_path: &str,
161    kind: IndexKind,
162) -> Result<(), StandingRootError> {
163    let updated = conn.execute(
164        "UPDATE standing_root_freshness
165         SET needs_strict_verify = 1, strict_verified_at = NULL
166         WHERE literal_path = ?1 AND index_kind = ?2",
167        params![literal_path, kind.as_str()],
168    )?;
169    if updated == 1 {
170        Ok(())
171    } else {
172        Err(StandingRootError::MissingFreshnessRow {
173            literal_path: literal_path.to_string(),
174            kind,
175        })
176    }
177}
178
179/// Atomically persist a successful strict verification and clear its freshness
180/// flag. Failed or interrupted verification must not call this operation.
181pub fn record_successful_strict_verification(
182    conn: &mut Connection,
183    literal_path: &str,
184    kind: IndexKind,
185    verified_at_ms: i64,
186) -> Result<(), StandingRootError> {
187    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
188    record_successful_strict_verification_in_transaction(&tx, literal_path, kind, verified_at_ms)?;
189    tx.commit()?;
190    Ok(())
191}
192
193/// Transactional half of [`record_successful_strict_verification`]. It is
194/// public for callers that also persist their successful verification outcome;
195/// dropping the transaction before commit leaves `needs_strict_verify` set.
196pub fn record_successful_strict_verification_in_transaction(
197    tx: &Transaction<'_>,
198    literal_path: &str,
199    kind: IndexKind,
200    verified_at_ms: i64,
201) -> Result<(), StandingRootError> {
202    let updated = tx.execute(
203        "UPDATE standing_root_freshness
204         SET strict_verified_at = ?3, needs_strict_verify = 0
205         WHERE literal_path = ?1 AND index_kind = ?2",
206        params![literal_path, kind.as_str(), verified_at_ms],
207    )?;
208    if updated == 1 {
209        Ok(())
210    } else {
211        Err(StandingRootError::MissingFreshnessRow {
212            literal_path: literal_path.to_string(),
213            kind,
214        })
215    }
216}
217
218fn get_standing_root_tx(
219    tx: &Transaction<'_>,
220    literal_path: &str,
221) -> Result<Option<StandingRootRecord>, StandingRootError> {
222    tx.query_row(
223        "SELECT literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
224         FROM standing_roots WHERE literal_path = ?1",
225        [literal_path],
226        |row| {
227            Ok(StandingRootRecord {
228                literal_path: row.get(0)?,
229                resolved_target: row.get(1)?,
230                resolved_git_toplevel: row.get(2)?,
231                scoped_relative_path: row.get(3)?,
232            })
233        },
234    )
235    .optional()
236    .map_err(Into::into)
237}
238
239fn ensure_same_identity(
240    recorded: &StandingRootRecord,
241    candidate: &StandingRootRecord,
242) -> Result<(), StandingRootError> {
243    for (field, recorded_value, candidate_value) in [
244        (
245            "resolved_target",
246            Some(recorded.resolved_target.clone()),
247            Some(candidate.resolved_target.clone()),
248        ),
249        (
250            "resolved_git_toplevel",
251            recorded.resolved_git_toplevel.clone(),
252            candidate.resolved_git_toplevel.clone(),
253        ),
254        (
255            "scoped_relative_path",
256            recorded.scoped_relative_path.clone(),
257            candidate.scoped_relative_path.clone(),
258        ),
259    ] {
260        if recorded_value != candidate_value {
261            return Err(StandingRootError::ResolvedPathDrift {
262                literal_path: candidate.literal_path.clone(),
263                field,
264                recorded: recorded_value,
265                resolved: candidate_value,
266            });
267        }
268    }
269    Ok(())
270}
271
272fn reconcile_freshness_rows(
273    tx: &Transaction<'_>,
274    literal_path: &str,
275    selected_kinds: &[IndexKind],
276    creating: bool,
277) -> Result<(), StandingRootError> {
278    for kind in IndexKind::ALL {
279        if selected_kinds.contains(&kind) {
280            tx.execute(
281                "INSERT INTO standing_root_freshness (
282                    literal_path, index_kind, needs_strict_verify, strict_verified_at
283                 ) VALUES (?1, ?2, 1, NULL)
284                 ON CONFLICT(literal_path, index_kind) DO NOTHING",
285                params![literal_path, kind.as_str()],
286            )?;
287        } else if !creating {
288            tx.execute(
289                "DELETE FROM standing_root_freshness
290                 WHERE literal_path = ?1 AND index_kind = ?2",
291                params![literal_path, kind.as_str()],
292            )?;
293        }
294    }
295    Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::db;
302    use tempfile::tempdir;
303
304    fn record(path: &str, target: &str) -> StandingRootRecord {
305        StandingRootRecord {
306            literal_path: path.to_string(),
307            resolved_target: target.to_string(),
308            resolved_git_toplevel: Some("/repo".to_string()),
309            scoped_relative_path: Some("src".to_string()),
310        }
311    }
312
313    #[test]
314    fn root_identity_is_machine_scoped_and_pins_literal_spelling() {
315        let dir = tempdir().unwrap();
316        let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
317        let first = record("~/work/src", "/real/repo/src");
318        assert_eq!(
319            ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
320                .unwrap(),
321            EnsureStandingRoot::Created
322        );
323        assert!(
324            needs_strict_verify(&conn, "~/work/src", IndexKind::Semantic)
325                .unwrap()
326                .unwrap()
327        );
328        assert!(needs_strict_verify(&conn, "~/work/src", IndexKind::Search)
329            .unwrap()
330            .unwrap());
331
332        assert_eq!(
333            ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
334                .unwrap(),
335            EnsureStandingRoot::Reloaded
336        );
337        let drift = ensure_standing_root(
338            &mut conn,
339            &record("~/work/src", "/retargeted/repo/src"),
340            &[IndexKind::Search],
341        )
342        .unwrap_err();
343        assert!(matches!(
344            drift,
345            StandingRootError::ResolvedPathDrift {
346                field: "resolved_target",
347                ..
348            }
349        ));
350        assert_eq!(
351            get_standing_root(&conn, "~/work/src")
352                .unwrap()
353                .unwrap()
354                .resolved_target,
355            "/real/repo/src"
356        );
357    }
358
359    #[test]
360    fn deletion_removes_resolution_and_freshness_without_artifact_gc() {
361        let dir = tempdir().unwrap();
362        let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
363        ensure_standing_root(
364            &mut conn,
365            &record("/one", "/repo/one"),
366            &[IndexKind::Search],
367        )
368        .unwrap();
369        ensure_standing_root(
370            &mut conn,
371            &record("/two", "/repo/two"),
372            &[IndexKind::Search],
373        )
374        .unwrap();
375        delete_standing_root(&conn, "/one").unwrap();
376        assert!(get_standing_root(&conn, "/one").unwrap().is_none());
377        assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
378            .unwrap()
379            .is_none());
380        assert!(get_standing_root(&conn, "/two").unwrap().is_some());
381    }
382
383    #[test]
384    fn strict_verification_clear_is_atomic_and_drop_before_commit_keeps_flag() {
385        let dir = tempdir().unwrap();
386        let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
387        ensure_standing_root(
388            &mut conn,
389            &record("/one", "/repo/one"),
390            &[IndexKind::Search],
391        )
392        .unwrap();
393
394        {
395            let tx = conn.transaction().unwrap();
396            record_successful_strict_verification_in_transaction(&tx, "/one", IndexKind::Search, 9)
397                .unwrap();
398            // Simulate a crash after successful verification but before commit.
399        }
400        assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
401            .unwrap()
402            .unwrap());
403
404        record_successful_strict_verification(&mut conn, "/one", IndexKind::Search, 10).unwrap();
405        assert!(!needs_strict_verify(&conn, "/one", IndexKind::Search)
406            .unwrap()
407            .unwrap());
408    }
409}