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/// Return a standing root's per-kind freshness state by its resolved target.
157/// Daemonless artifact readers know the canonical artifact root, not the user
158/// configuration spelling that keys the durable row.
159pub fn needs_strict_verify_for_resolved_target(
160    conn: &Connection,
161    resolved_target: &str,
162    kind: IndexKind,
163) -> Result<Option<bool>, StandingRootError> {
164    conn.query_row(
165        "SELECT freshness.needs_strict_verify
166         FROM standing_roots AS roots
167         JOIN standing_root_freshness AS freshness
168           ON freshness.literal_path = roots.literal_path
169         WHERE roots.resolved_target = ?1 AND freshness.index_kind = ?2",
170        params![resolved_target, kind.as_str()],
171        |row| Ok(row.get::<_, i64>(0)? != 0),
172    )
173    .optional()
174    .map_err(Into::into)
175}
176
177/// Record an observation gap. A later strict verification is the only API that
178/// clears this flag.
179pub fn mark_needs_strict_verify(
180    conn: &Connection,
181    literal_path: &str,
182    kind: IndexKind,
183) -> Result<(), StandingRootError> {
184    let updated = conn.execute(
185        "UPDATE standing_root_freshness
186         SET needs_strict_verify = 1, strict_verified_at = NULL
187         WHERE literal_path = ?1 AND index_kind = ?2",
188        params![literal_path, kind.as_str()],
189    )?;
190    if updated == 1 {
191        Ok(())
192    } else {
193        Err(StandingRootError::MissingFreshnessRow {
194            literal_path: literal_path.to_string(),
195            kind,
196        })
197    }
198}
199
200/// Atomically persist a successful strict verification and clear its freshness
201/// flag. Failed or interrupted verification must not call this operation.
202pub fn record_successful_strict_verification(
203    conn: &mut Connection,
204    literal_path: &str,
205    kind: IndexKind,
206    verified_at_ms: i64,
207) -> Result<(), StandingRootError> {
208    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
209    record_successful_strict_verification_in_transaction(&tx, literal_path, kind, verified_at_ms)?;
210    tx.commit()?;
211    Ok(())
212}
213
214/// Transactional half of [`record_successful_strict_verification`]. It is
215/// public for callers that also persist their successful verification outcome;
216/// dropping the transaction before commit leaves `needs_strict_verify` set.
217pub fn record_successful_strict_verification_in_transaction(
218    tx: &Transaction<'_>,
219    literal_path: &str,
220    kind: IndexKind,
221    verified_at_ms: i64,
222) -> Result<(), StandingRootError> {
223    let updated = tx.execute(
224        "UPDATE standing_root_freshness
225         SET strict_verified_at = ?3, needs_strict_verify = 0
226         WHERE literal_path = ?1 AND index_kind = ?2",
227        params![literal_path, kind.as_str(), verified_at_ms],
228    )?;
229    if updated == 1 {
230        Ok(())
231    } else {
232        Err(StandingRootError::MissingFreshnessRow {
233            literal_path: literal_path.to_string(),
234            kind,
235        })
236    }
237}
238
239fn get_standing_root_tx(
240    tx: &Transaction<'_>,
241    literal_path: &str,
242) -> Result<Option<StandingRootRecord>, StandingRootError> {
243    tx.query_row(
244        "SELECT literal_path, resolved_target, resolved_git_toplevel, scoped_relative_path
245         FROM standing_roots WHERE literal_path = ?1",
246        [literal_path],
247        |row| {
248            Ok(StandingRootRecord {
249                literal_path: row.get(0)?,
250                resolved_target: row.get(1)?,
251                resolved_git_toplevel: row.get(2)?,
252                scoped_relative_path: row.get(3)?,
253            })
254        },
255    )
256    .optional()
257    .map_err(Into::into)
258}
259
260fn ensure_same_identity(
261    recorded: &StandingRootRecord,
262    candidate: &StandingRootRecord,
263) -> Result<(), StandingRootError> {
264    for (field, recorded_value, candidate_value) in [
265        (
266            "resolved_target",
267            Some(recorded.resolved_target.clone()),
268            Some(candidate.resolved_target.clone()),
269        ),
270        (
271            "resolved_git_toplevel",
272            recorded.resolved_git_toplevel.clone(),
273            candidate.resolved_git_toplevel.clone(),
274        ),
275        (
276            "scoped_relative_path",
277            recorded.scoped_relative_path.clone(),
278            candidate.scoped_relative_path.clone(),
279        ),
280    ] {
281        if recorded_value != candidate_value {
282            return Err(StandingRootError::ResolvedPathDrift {
283                literal_path: candidate.literal_path.clone(),
284                field,
285                recorded: recorded_value,
286                resolved: candidate_value,
287            });
288        }
289    }
290    Ok(())
291}
292
293fn reconcile_freshness_rows(
294    tx: &Transaction<'_>,
295    literal_path: &str,
296    selected_kinds: &[IndexKind],
297    creating: bool,
298) -> Result<(), StandingRootError> {
299    for kind in IndexKind::ALL {
300        if selected_kinds.contains(&kind) {
301            tx.execute(
302                "INSERT INTO standing_root_freshness (
303                    literal_path, index_kind, needs_strict_verify, strict_verified_at
304                 ) VALUES (?1, ?2, 1, NULL)
305                 ON CONFLICT(literal_path, index_kind) DO NOTHING",
306                params![literal_path, kind.as_str()],
307            )?;
308        } else if !creating {
309            tx.execute(
310                "DELETE FROM standing_root_freshness
311                 WHERE literal_path = ?1 AND index_kind = ?2",
312                params![literal_path, kind.as_str()],
313            )?;
314        }
315    }
316    Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::db;
323    use tempfile::tempdir;
324
325    fn record(path: &str, target: &str) -> StandingRootRecord {
326        StandingRootRecord {
327            literal_path: path.to_string(),
328            resolved_target: target.to_string(),
329            resolved_git_toplevel: Some("/repo".to_string()),
330            scoped_relative_path: Some("src".to_string()),
331        }
332    }
333
334    #[test]
335    fn root_identity_is_machine_scoped_and_pins_literal_spelling() {
336        let dir = tempdir().unwrap();
337        let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
338        let first = record("~/work/src", "/real/repo/src");
339        assert_eq!(
340            ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
341                .unwrap(),
342            EnsureStandingRoot::Created
343        );
344        assert!(
345            needs_strict_verify(&conn, "~/work/src", IndexKind::Semantic)
346                .unwrap()
347                .unwrap()
348        );
349        assert!(needs_strict_verify(&conn, "~/work/src", IndexKind::Search)
350            .unwrap()
351            .unwrap());
352
353        assert_eq!(
354            ensure_standing_root(&mut conn, &first, &[IndexKind::Search, IndexKind::Semantic])
355                .unwrap(),
356            EnsureStandingRoot::Reloaded
357        );
358        let drift = ensure_standing_root(
359            &mut conn,
360            &record("~/work/src", "/retargeted/repo/src"),
361            &[IndexKind::Search],
362        )
363        .unwrap_err();
364        assert!(matches!(
365            drift,
366            StandingRootError::ResolvedPathDrift {
367                field: "resolved_target",
368                ..
369            }
370        ));
371        assert_eq!(
372            get_standing_root(&conn, "~/work/src")
373                .unwrap()
374                .unwrap()
375                .resolved_target,
376            "/real/repo/src"
377        );
378    }
379
380    #[test]
381    fn deletion_removes_resolution_and_freshness_without_artifact_gc() {
382        let dir = tempdir().unwrap();
383        let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
384        ensure_standing_root(
385            &mut conn,
386            &record("/one", "/repo/one"),
387            &[IndexKind::Search],
388        )
389        .unwrap();
390        ensure_standing_root(
391            &mut conn,
392            &record("/two", "/repo/two"),
393            &[IndexKind::Search],
394        )
395        .unwrap();
396        delete_standing_root(&conn, "/one").unwrap();
397        assert!(get_standing_root(&conn, "/one").unwrap().is_none());
398        assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
399            .unwrap()
400            .is_none());
401        assert!(get_standing_root(&conn, "/two").unwrap().is_some());
402    }
403
404    #[test]
405    fn strict_verification_clear_is_atomic_and_drop_before_commit_keeps_flag() {
406        let dir = tempdir().unwrap();
407        let mut conn = db::open(&dir.path().join("aft.db")).unwrap();
408        ensure_standing_root(
409            &mut conn,
410            &record("/one", "/repo/one"),
411            &[IndexKind::Search],
412        )
413        .unwrap();
414
415        {
416            let tx = conn.transaction().unwrap();
417            record_successful_strict_verification_in_transaction(&tx, "/one", IndexKind::Search, 9)
418                .unwrap();
419            // Simulate a crash after successful verification but before commit.
420        }
421        assert!(needs_strict_verify(&conn, "/one", IndexKind::Search)
422            .unwrap()
423            .unwrap());
424
425        record_successful_strict_verification(&mut conn, "/one", IndexKind::Search, 10).unwrap();
426        assert!(!needs_strict_verify(&conn, "/one", IndexKind::Search)
427            .unwrap()
428            .unwrap());
429    }
430}