Skip to main content

ai_memory/kg/
cycle_check.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Anti-self-reflection cycle detection for `reflects_on` edges.
5//!
6//! The `reflects_on` relation is directional: `A reflects_on B` means "A was
7//! derived from B". A cycle in this relation — e.g. `A → B → A` — is a
8//! logical contradiction (A derived from B which was derived from A) and must
9//! be refused before the edge is persisted.
10//!
11//! [`would_create_reflection_cycle`] performs a bounded backward walk from
12//! `target_id` following `reflects_on` edges, returning `true` when `source_id`
13//! is reachable (cycle detected) or `false` otherwise. The walk is bounded by
14//! `max_depth` to prevent runaway traversal in deep reflection graphs;
15//! [`cycle_path`] carries the walk log for the refusal audit row.
16
17use rusqlite::{Connection, params};
18
19/// Safety ceiling applied when a caller passes `max_depth = 0` (i.e. no
20/// explicit cap). The policy default for reflection depth lives elsewhere —
21/// see `GovernancePolicy::effective_max_reflection_depth()` for the runtime
22/// value the orchestration layer hands the cycle-check walk. This constant
23/// is intentionally larger than that policy default so it never silently
24/// truncates legitimate walks; it exists solely so an unset/zero cap can
25/// still bound a pathological reflection graph.
26const DEFAULT_MAX_DEPTH: u32 = 16;
27
28/// Headroom multiplier applied to the caller's `max_depth` to derive the
29/// walk's hard ceiling. A legal `reflects_on` graph is a DAG whose
30/// longest path is bounded by the namespace's
31/// `effective_max_reflection_depth` (the link-write path refuses edges
32/// that would exceed it), so doubling that depth gives a legitimate deep
33/// chain enough room to fully resolve (frontier empties) before the
34/// ceiling is reached. Only an already-over-deep / corrupt graph can
35/// reach `max_depth * CYCLE_DEPTH_SAFETY_FACTOR` with nodes still
36/// unexplored — and that case fails CLOSED (see
37/// [`would_create_reflection_cycle`]).
38const CYCLE_DEPTH_SAFETY_FACTOR: u32 = 2;
39
40/// Result of a cycle-check walk: whether a cycle would be created, and if so,
41/// the full path from `source_id` back to `source_id` via `target_id`.
42///
43/// `cycle_path` is ordered `source_id → target_id → … → source_id`.  When
44/// `would_cycle` is `false`, `cycle_path` is empty.
45pub struct CycleCheckResult {
46    pub would_cycle: bool,
47    pub cycle_path: Vec<String>,
48}
49
50/// Walk `reflects_on` edges **forward** from `target_id`, bounded by
51/// `max_depth` hops.  Returns `true` when `source_id` is reachable from
52/// `target_id` by following existing edges (i.e. adding edge
53/// `source_id → target_id` would close a cycle).
54///
55/// The forward walk direction: a `reflects_on` edge `(source=A, target=B)`
56/// means "A reflects on B".  In graph terms the directed arc goes A → B.
57/// To detect if adding `source → target` creates a cycle we walk forward from
58/// `target` via existing edges and check whether we can reach `source`.  If
59/// yes, the proposed edge would close the loop.
60///
61/// Example: existing edges A→B and B→C.  Proposed: C→A.  Walk forward from A:
62///   hop 1: {B}  hop 2: {C}  — found A is not in the visited set.  But wait,
63///   we walk from `target` (A in the proposed C→A), forward, and check if
64///   we find `source` (C).  Hop 1 from A: B.  Hop 2 from B: C.  C == source!
65///   Cycle detected.
66///
67/// Returns a [`CycleCheckResult`] with `would_cycle = true` and the full path
68/// when a cycle is found, or `would_cycle = false` with an empty path
69/// otherwise.
70///
71/// # Errors
72///
73/// v0.7.0 #1090 (SR-2 #5, MEDIUM): SQL failures during the walk now
74/// propagate as `Err` (fail-CLOSED) to match the #1053 / #1054
75/// policy. Pre-#1090 a transient `SQLITE_BUSY` during the walk was
76/// silently treated as "no cycle, continue" — letting the substrate
77/// land a `reflects_on` edge that closes a cycle when the DB was
78/// briefly stressed. The cycle check is a substrate-level governance
79/// gate; under load it MUST fail-CLOSED so an adversary cannot ride
80/// transient DB pressure to slip a logically-invalid edge past the
81/// gate. Callers wrap the err in a refusal envelope (`db::create_link`
82/// surfaces it directly via `?`).
83///
84/// # Depth-bound (fail-CLOSED on truncation)
85///
86/// v0.7.0 SR — the walk is bounded at
87/// `max_depth * `[`CYCLE_DEPTH_SAFETY_FACTOR`] hops. Pre-fix, reaching
88/// the bound with unexplored nodes still in the frontier returned
89/// `would_cycle = false` — a **false negative** that let a cycle which
90/// closes beyond the bound slip past the gate. A legal `reflects_on`
91/// DAG can never have a path longer than `max_depth`, and the safety
92/// factor gives a legitimate deep chain room to resolve fully before
93/// the ceiling, so a still-non-empty frontier at the ceiling means the
94/// existing graph is already over-deep (corrupt) — the walk now fails
95/// CLOSED (`would_cycle = true`) rather than silently allowing the
96/// edge. This trades a possible conservative refusal on an
97/// already-corrupt graph for the elimination of the silent-cycle
98/// false negative, matching the gate's fail-CLOSED posture.
99pub fn would_create_reflection_cycle(
100    conn: &Connection,
101    source_id: &str,
102    target_id: &str,
103    max_depth: u32,
104) -> rusqlite::Result<CycleCheckResult> {
105    would_create_reflection_cycle_with(source_id, target_id, max_depth, &mut |node| {
106        forward_neighbors(conn, node)
107    })
108}
109
110/// #1568 (H1 residual) — derive the walk's hard hop ceiling from the
111/// caller's `max_depth` (policy default applied on 0, then the
112/// [`CYCLE_DEPTH_SAFETY_FACTOR`] headroom). Exported so the postgres
113/// SAL adapter can pre-fetch exactly the bounded `reflects_on`
114/// subgraph the generic walker will explore.
115#[must_use]
116pub fn walk_bound(max_depth: u32) -> u32 {
117    let base = if max_depth == 0 {
118        DEFAULT_MAX_DEPTH
119    } else {
120        max_depth
121    };
122    base.saturating_mul(CYCLE_DEPTH_SAFETY_FACTOR)
123}
124
125/// #1568 (H1 residual) — backend-agnostic core of
126/// [`would_create_reflection_cycle`]. The BFS semantics (visited set,
127/// predecessor tracking, fail-CLOSED on depth-ceiling truncation, SQL
128/// errors propagated as `Err`) live HERE, once; backends supply only
129/// the `neighbors` lookup. The sqlite path passes a closure over
130/// `forward_neighbors(conn, _)`; the postgres SAL adapter pre-fetches
131/// the bounded `reflects_on` subgraph via a recursive CTE and passes a
132/// closure over the in-memory adjacency map — so the two adapters
133/// cannot drift on gate semantics.
134///
135/// # Errors
136///
137/// Propagates whatever error the `neighbors` lookup surfaces
138/// (fail-CLOSED — see [`would_create_reflection_cycle`]).
139pub fn would_create_reflection_cycle_with<E>(
140    source_id: &str,
141    target_id: &str,
142    max_depth: u32,
143    neighbors: &mut dyn FnMut(&str) -> Result<Vec<String>, E>,
144) -> Result<CycleCheckResult, E> {
145    // Direct self-link is already blocked by `validate_link`; handle it
146    // defensively here too so the audit path is always consistent.
147    if source_id == target_id {
148        return Ok(CycleCheckResult {
149            would_cycle: true,
150            cycle_path: vec![source_id.to_string(), target_id.to_string()],
151        });
152    }
153
154    let bound = walk_bound(max_depth);
155
156    // BFS / iterative DFS over the backward reflects_on graph.
157    // `visited` prevents revisiting nodes in diamond-shaped subgraphs.
158    // `path_map` tracks the predecessor for each visited node so we can
159    // reconstruct the cycle path if `source_id` is found.
160    let mut frontier: Vec<String> = vec![target_id.to_string()];
161    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
162    // predecessor[node] = the node from which we first reached `node`
163    let mut predecessor: std::collections::HashMap<String, String> =
164        std::collections::HashMap::new();
165    visited.insert(target_id.to_string());
166
167    let mut depth = 0u32;
168
169    while !frontier.is_empty() {
170        if depth >= bound {
171            // v0.7.0 SR — depth ceiling reached with nodes still
172            // unexplored. A legal DAG would have emptied the frontier
173            // by now (longest legal path <= max_depth, doubled for
174            // headroom), so the existing graph is over-deep / corrupt.
175            // Fail CLOSED: refuse rather than return the pre-fix
176            // false-negative `would_cycle = false`.
177            return Ok(CycleCheckResult {
178                would_cycle: true,
179                cycle_path: vec![source_id.to_string(), target_id.to_string()],
180            });
181        }
182        depth += 1;
183        let mut next_frontier: Vec<String> = Vec::new();
184
185        for current in &frontier {
186            // v0.7.0 #1090 — propagate lookup errors as Err. The forward
187            // walk is a substrate-level governance gate; a transient
188            // BUSY/LOCKED here MUST surface so the caller refuses the
189            // write rather than landing a logically-invalid edge.
190            let step = neighbors(current)?;
191
192            for neighbor in step {
193                if neighbor == source_id {
194                    // Cycle found: reconstruct path.
195                    let path = reconstruct_path(source_id, target_id, current, &predecessor);
196                    return Ok(CycleCheckResult {
197                        would_cycle: true,
198                        cycle_path: path,
199                    });
200                }
201                if visited.insert(neighbor.clone()) {
202                    predecessor.insert(neighbor.clone(), current.clone());
203                    next_frontier.push(neighbor);
204                }
205            }
206        }
207
208        frontier = next_frontier;
209    }
210
211    Ok(CycleCheckResult {
212        would_cycle: false,
213        cycle_path: vec![],
214    })
215}
216
217/// Return the set of nodes reachable from `node` via `reflects_on` edges
218/// (i.e. the "targets" in rows where `source_id = node` and
219/// `relation = 'reflects_on'`).
220fn forward_neighbors(conn: &Connection, node: &str) -> rusqlite::Result<Vec<String>> {
221    let mut stmt = conn.prepare_cached(
222        "SELECT target_id FROM memory_links \
223         WHERE source_id = ?1 AND relation = 'reflects_on'",
224    )?;
225    let rows = stmt.query_map(params![node], |row| row.get(0))?;
226    rows.collect()
227}
228
229/// Reconstruct the cycle path given the predecessor map.
230///
231/// The cycle is: `source_id → target_id → … → found_at → source_id`.
232/// We build the segment `target_id → … → found_at` by walking predecessors
233/// backward from `found_at` to `target_id`, then prepend `source_id` and
234/// append `source_id` again to close the loop.
235fn reconstruct_path(
236    source_id: &str,
237    target_id: &str,
238    found_at: &str,
239    predecessor: &std::collections::HashMap<String, String>,
240) -> Vec<String> {
241    // Walk from `found_at` back to `target_id` using predecessor pointers.
242    let mut segment: Vec<String> = vec![found_at.to_string()];
243    let mut cur = found_at;
244    // Predecessor of `target_id` would be absent (it's the root of the
245    // backward walk), so this loop terminates.
246    while let Some(pred) = predecessor.get(cur) {
247        segment.push(pred.clone());
248        cur = pred;
249        if cur == target_id {
250            break;
251        }
252    }
253    segment.reverse();
254
255    // Full cycle: source → target → [middle] → found_at → source
256    let mut path = Vec::with_capacity(segment.len() + 2);
257    path.push(source_id.to_string());
258    path.extend(segment);
259    path.push(source_id.to_string());
260    path
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use rusqlite::Connection;
267
268    fn open_db() -> Connection {
269        crate::db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
270    }
271
272    fn insert_memory(conn: &Connection, id: &str) {
273        use crate::models::{Memory, Tier};
274        use chrono::Utc;
275        let now = Utc::now().to_rfc3339();
276        let mem = Memory {
277            id: id.to_string(),
278            tier: Tier::Mid,
279            namespace: "test".to_string(),
280            title: format!("memory-{id}"),
281            content: "content".to_string(),
282            tags: vec![],
283            priority: 5,
284            confidence: 1.0,
285            source: "test".to_string(),
286            access_count: 0,
287            created_at: now.clone(),
288            updated_at: now,
289            last_accessed_at: None,
290            expires_at: None,
291            metadata: serde_json::json!({"agent_id": "test-agent"}),
292            reflection_depth: 0,
293            memory_kind: crate::models::MemoryKind::Observation,
294            entity_id: None,
295            persona_version: None,
296            citations: Vec::new(),
297            source_uri: None,
298            source_span: None,
299            confidence_source: crate::models::ConfidenceSource::CallerProvided,
300            confidence_signals: None,
301            confidence_decayed_at: None,
302            version: 1,
303        };
304        crate::db::insert(conn, &mem).expect("insert memory");
305    }
306
307    fn add_reflects_on(conn: &Connection, source_id: &str, target_id: &str) {
308        crate::db::create_link(conn, source_id, target_id, "reflects_on")
309            .expect("create reflects_on link");
310    }
311
312    // ── #1568 (H1 residual) — backend-agnostic walker over an
313    // in-memory adjacency map, the exact shape the postgres SAL
314    // adapter feeds after its bounded recursive-CTE prefetch. Pins
315    // that the shared core detects cycles and fail-CLOSES on
316    // truncation independent of the rusqlite-bound wrapper.
317
318    fn map_neighbors<'a>(
319        adjacency: &'a std::collections::HashMap<&'static str, Vec<&'static str>>,
320    ) -> impl FnMut(&str) -> Result<Vec<String>, std::convert::Infallible> + 'a {
321        move |node: &str| {
322            Ok(adjacency
323                .get(node)
324                .map(|v| v.iter().map(ToString::to_string).collect())
325                .unwrap_or_default())
326        }
327    }
328
329    #[test]
330    fn generic_walker_detects_cycle_over_adjacency_map_1568() {
331        // Existing edges: a→b, b→c. Proposed c→a closes the loop.
332        let adjacency = std::collections::HashMap::from([("a", vec!["b"]), ("b", vec!["c"])]);
333        let mut neighbors = map_neighbors(&adjacency);
334        let hit = would_create_reflection_cycle_with("c", "a", 8, &mut neighbors).expect("ok");
335        assert!(hit.would_cycle, "c→a must close the a→b→c chain");
336        assert_eq!(hit.cycle_path, vec!["c", "a", "b", "c"]);
337
338        // Proposed x→a touches the chain but closes nothing.
339        let legal = would_create_reflection_cycle_with("x", "a", 8, &mut neighbors).expect("ok");
340        assert!(!legal.would_cycle);
341        assert!(legal.cycle_path.is_empty());
342    }
343
344    #[test]
345    fn generic_walker_fails_closed_on_truncation_1568() {
346        // Chain a→b→c→d→e→f→g; cap 2 → bound 4. The walk from g's
347        // perspective: proposed a→g, walk forward from g — frontier
348        // empties immediately (no out-edges from g), legal. Walk for
349        // proposed g→a: forward from a runs 6 hops > bound 4 with
350        // nodes still unexplored → fail CLOSED.
351        let adjacency = std::collections::HashMap::from([
352            ("a", vec!["b"]),
353            ("b", vec!["c"]),
354            ("c", vec!["d"]),
355            ("d", vec!["e"]),
356            ("e", vec!["f"]),
357            ("f", vec!["g"]),
358        ]);
359        let mut neighbors = map_neighbors(&adjacency);
360        let truncated =
361            would_create_reflection_cycle_with("g", "a", 2, &mut neighbors).expect("ok");
362        assert!(
363            truncated.would_cycle,
364            "ceiling reached with non-empty frontier must fail CLOSED"
365        );
366        // Same walk with a high cap resolves fully: g IS reachable
367        // from a, so the proposed g→a edge genuinely closes a cycle.
368        let resolved = would_create_reflection_cycle_with("g", "a", 8, &mut neighbors).expect("ok");
369        assert!(resolved.would_cycle);
370        // And a node OFF the chain with a high cap is legal.
371        let legal = would_create_reflection_cycle_with("zz", "a", 8, &mut neighbors).expect("ok");
372        assert!(!legal.would_cycle);
373    }
374
375    // ── Unit tests for the internal cycle-check machinery ─────────────
376
377    #[test]
378    fn no_edges_is_no_cycle() {
379        let conn = open_db();
380        insert_memory(&conn, "a");
381        insert_memory(&conn, "b");
382        // No links yet — adding A→B is safe.
383        let result = would_create_reflection_cycle(&conn, "a", "b", 8).expect("ok");
384        assert!(!result.would_cycle);
385        assert!(result.cycle_path.is_empty());
386    }
387
388    #[test]
389    fn direct_cycle_detected() {
390        // Existing: B→A. Proposed: A→B. Would close A→B→A.
391        let conn = open_db();
392        insert_memory(&conn, "a");
393        insert_memory(&conn, "b");
394        add_reflects_on(&conn, "b", "a"); // B reflects_on A
395
396        let result = would_create_reflection_cycle(&conn, "a", "b", 8).expect("ok");
397        assert!(
398            result.would_cycle,
399            "direct cycle A→B with B→A must be detected"
400        );
401        assert!(!result.cycle_path.is_empty());
402        // Path must start and end with source_id ("a")
403        assert_eq!(result.cycle_path.first().map(String::as_str), Some("a"));
404        assert_eq!(result.cycle_path.last().map(String::as_str), Some("a"));
405    }
406
407    #[test]
408    fn indirect_cycle_detected() {
409        // Existing: A→B, B→C. Proposed: C→A. Would close C→A→B→C.
410        let conn = open_db();
411        insert_memory(&conn, "a");
412        insert_memory(&conn, "b");
413        insert_memory(&conn, "c");
414        add_reflects_on(&conn, "a", "b"); // A reflects_on B
415        add_reflects_on(&conn, "b", "c"); // B reflects_on C
416
417        // Proposed: C reflects_on A
418        let result = would_create_reflection_cycle(&conn, "c", "a", 8).expect("ok");
419        assert!(
420            result.would_cycle,
421            "indirect cycle C→A with A→B→C must be detected"
422        );
423        assert!(!result.cycle_path.is_empty());
424        assert_eq!(result.cycle_path.first().map(String::as_str), Some("c"));
425        assert_eq!(result.cycle_path.last().map(String::as_str), Some("c"));
426    }
427
428    #[test]
429    fn non_cycle_succeeds() {
430        // Existing: A→B. Proposed: C→B. C is unrelated to A — no cycle.
431        let conn = open_db();
432        insert_memory(&conn, "a");
433        insert_memory(&conn, "b");
434        insert_memory(&conn, "c");
435        add_reflects_on(&conn, "a", "b"); // A reflects_on B (existing)
436
437        // Adding C→B: walk backward from B finds A, not C. Safe.
438        let result = would_create_reflection_cycle(&conn, "c", "b", 8).expect("ok");
439        assert!(
440            !result.would_cycle,
441            "C→B with only A→B existing is not a cycle"
442        );
443        assert!(result.cycle_path.is_empty());
444    }
445
446    #[test]
447    fn legal_deep_chain_within_safety_headroom_resolves() {
448        // Chain: E→D→C→B→A (4 hops). Proposed: C→D is NOT a cycle (C already
449        // reflects_on B, and D is upstream of C). We pick a NON-cyclic deep
450        // probe to prove a legitimate chain whose longest path is within the
451        // caller's `max_depth` empties the frontier BEFORE the
452        // `max_depth * CYCLE_DEPTH_SAFETY_FACTOR` ceiling — i.e. the safety
453        // factor gives legal chains room to resolve without a false positive.
454        let conn = open_db();
455        for id in ["a", "b", "c", "d", "e"] {
456            insert_memory(&conn, id);
457        }
458        add_reflects_on(&conn, "e", "d");
459        add_reflects_on(&conn, "d", "c");
460        add_reflects_on(&conn, "c", "b");
461        add_reflects_on(&conn, "b", "a");
462
463        // Propose X→A where X is a fresh node unrelated to the chain. Walk
464        // forward from A finds nothing (A is the chain tail, no outgoing
465        // reflects_on), so the frontier empties immediately → no cycle, no
466        // ceiling hit, even at the smallest non-zero max_depth.
467        insert_memory(&conn, "x");
468        let legal = would_create_reflection_cycle(&conn, "x", "a", 1).expect("ok");
469        assert!(
470            !legal.would_cycle,
471            "a chain tail with no outgoing edges must resolve as no-cycle"
472        );
473        assert!(legal.cycle_path.is_empty());
474    }
475
476    // v0.7.0 SR — depth-bound false-negative fix. Pre-fix, a real cycle
477    // that closed BEYOND the bound returned `would_cycle = false` (a
478    // silent false negative that let the substrate land a cycle-creating
479    // `reflects_on` edge). Post-fix the walk fails CLOSED: reaching the
480    // `max_depth * CYCLE_DEPTH_SAFETY_FACTOR` ceiling with nodes still
481    // unexplored returns `would_cycle = true`.
482    #[test]
483    fn depth_bound_fails_closed_on_truncation() {
484        // Chain: G→F→E→D→C→B→A (6 hops). Proposed: A→G closes a 7-node cycle.
485        let conn = open_db();
486        for id in ["a", "b", "c", "d", "e", "f", "g"] {
487            insert_memory(&conn, id);
488        }
489        add_reflects_on(&conn, "g", "f");
490        add_reflects_on(&conn, "f", "e");
491        add_reflects_on(&conn, "e", "d");
492        add_reflects_on(&conn, "d", "c");
493        add_reflects_on(&conn, "c", "b");
494        add_reflects_on(&conn, "b", "a");
495
496        // max_depth=2 → ceiling = 2 * CYCLE_DEPTH_SAFETY_FACTOR = 4 hops.
497        // Walking forward from G reaches C at the ceiling with B, A still
498        // unexplored. Pre-fix this returned `would_cycle = false` (the real
499        // A→G…→A cycle slips past). Post-fix it fails CLOSED.
500        let truncated = would_create_reflection_cycle(&conn, "a", "g", 2).expect("ok");
501        assert!(
502            truncated.would_cycle,
503            "ceiling reached with unexplored frontier must fail CLOSED (would_cycle=true)"
504        );
505        assert!(
506            !truncated.cycle_path.is_empty(),
507            "fail-CLOSED result must carry a non-empty audit path"
508        );
509
510        // With max_depth=6 → ceiling = 12, the full chain resolves and the
511        // walk locates the actual cycle (not a ceiling fallback).
512        let resolved = would_create_reflection_cycle(&conn, "a", "g", 6).expect("ok");
513        assert!(
514            resolved.would_cycle,
515            "with adequate depth the real cycle must be detected"
516        );
517        assert_eq!(resolved.cycle_path.first().map(String::as_str), Some("a"));
518        assert_eq!(resolved.cycle_path.last().map(String::as_str), Some("a"));
519    }
520
521    // ---- C-5 (#699): close remaining gaps in cycle_check.rs.
522    // Targets: lines 70-73 (direct self-link defensive branch), line 77
523    // (`max_depth == 0` fallback to DEFAULT_MAX_DEPTH). ----
524
525    #[test]
526    fn direct_self_link_returns_cycle_with_two_node_path() {
527        // Lines 70-73: when source_id == target_id, the function bails
528        // immediately with would_cycle = true and a two-node path
529        // `[source, target]`. This is defensive coverage; the validator
530        // also blocks self-links upstream.
531        let conn = open_db();
532        insert_memory(&conn, "self");
533
534        let result = would_create_reflection_cycle(&conn, "self", "self", 8).expect("ok");
535        assert!(
536            result.would_cycle,
537            "direct self-link must be flagged as a cycle"
538        );
539        assert_eq!(
540            result.cycle_path,
541            vec!["self".to_string(), "self".to_string()]
542        );
543    }
544
545    #[test]
546    fn max_depth_zero_falls_back_to_default_bound() {
547        // Line 77: `max_depth == 0` triggers the `DEFAULT_MAX_DEPTH`
548        // fallback. We assert the function still detects a real cycle
549        // when the caller passes the sentinel `0` (i.e. "use default").
550        let conn = open_db();
551        insert_memory(&conn, "a");
552        insert_memory(&conn, "b");
553        add_reflects_on(&conn, "b", "a"); // B reflects_on A
554
555        // Pass 0 to invoke the fallback branch.
556        let result = would_create_reflection_cycle(&conn, "a", "b", 0).expect("ok");
557        assert!(
558            result.would_cycle,
559            "max_depth=0 should fall back to DEFAULT_MAX_DEPTH and still detect the cycle"
560        );
561        assert!(!result.cycle_path.is_empty());
562    }
563
564    // v0.7.0 #1090 (SR-2 #5, MEDIUM) — fail-CLOSED: a forced SQL
565    // error during the forward walk propagates as Err rather than
566    // returning the misleading `would_cycle = false`.
567    //
568    // We seed a real cycle (B → A exists; proposed A → B) on a
569    // fresh DB so the walk WOULD detect it if it ran cleanly. Then
570    // we drop the `memory_links` table before the call so the
571    // walk's prepared SELECT hits a "no such table" rusqlite error
572    // — exactly the shape a transient corruption / BUSY / LOCKED
573    // would surface. Pre-#1090 the function would have logged
574    // `tracing::warn!` and returned `would_cycle = false` — letting
575    // the substrate land the cycle-creating edge under load. Post-
576    // #1090 the err propagates, the caller refuses the write.
577    #[test]
578    fn sql_error_fails_closed_1090() {
579        let conn = open_db();
580        insert_memory(&conn, "a");
581        insert_memory(&conn, "b");
582        // Drop the table the forward walk reads from. The next
583        // forward_neighbors call must surface the error.
584        conn.execute("DROP TABLE memory_links", []).expect("drop");
585        let result = would_create_reflection_cycle(&conn, "a", "b", 8);
586        assert!(
587            result.is_err(),
588            "SQL error during cycle walk must propagate as Err (#1090 fail-CLOSED)"
589        );
590    }
591}