Skip to main content

ai_memory/handlers/
http.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4// #873 — this file currently exceeds the 250-line per-function budget
5// in `create_memory` (#866) and several other large handlers; the
6// per-function `#[allow(clippy::too_many_lines)]` attributes inside
7// keep the warn-level lint green while the splits land. Module-level
8// allow is the belt-and-braces in case a function grows past
9// threshold without picking up its own attribute. Tracked for split
10// as #866 + #868.
11#![allow(clippy::too_many_lines)]
12
13use crate::db;
14#[cfg(feature = "sal")]
15use crate::models::Memory;
16
17use super::AppState;
18#[cfg(feature = "sal")]
19use super::StorageBackend;
20
21/// v0.7.0 L5 — minimum content length (chars) below which the HTTP
22/// `create_memory` handler skips the `auto_tag` autonomy hook. Mirrors
23/// the constant the MCP `handle_store` path uses (`AUTONOMY_MIN_CONTENT_LEN`
24/// at `crate::mcp::handle_store` (short-row skip)) so a memory that's too short to be meaningfully
25/// tagged doesn't burn a 30s Ollama round-trip on each store.
26const AUTO_TAG_MIN_CONTENT_LEN: usize = 50;
27/// v0.7.0 L5 — maximum number of auto-generated tags merged into the
28/// memory. Mirrors `mcp.rs:1827-1828` so postgres + sqlite + MCP all
29/// converge on the same on-disk shape.
30const AUTO_TAG_MAX_TAGS: usize = 8;
31
32/// v0.7.0 fold-A2A1.6 (#700, S16/S49) — `app.store.get` with bounded
33/// retry on [`crate::store::StoreError::NotFound`].
34///
35/// Why this exists: on a postgres-backed daemon a freshly-stored row
36/// can briefly return NotFound from the SAL `get` while WAL flush
37/// settles or the read query hits a still-replicating standby. The
38/// 22-failure A2A triage (memory `9ffaa55d`) classified this as
39/// Bucket-A: the row exists, the promote handler just races the
40/// visibility window. Returning a one-shot 404 surfaces a flake to
41/// the operator even though a 5 ms retry would have caught the
42/// (eventually-consistent) row.
43///
44/// Retry budget: 5 + 10 + 15 + 20 ms = 50 ms wall clock, evenly
45/// dwarfed by the 2 s daemon p99 SLO. Any other StoreError class
46/// (e.g. backend down, integrity failure) returns immediately
47/// without retry — those are not visibility-race symptoms.
48#[cfg(feature = "sal")]
49pub(super) async fn get_with_visibility_retry(
50    store: &dyn crate::store::MemoryStore,
51    ctx: &crate::store::CallerContext,
52    id: &str,
53) -> crate::store::StoreResult<Memory> {
54    let mut attempt: u32 = 0;
55    loop {
56        match store.get(ctx, id).await {
57            Ok(m) => return Ok(m),
58            Err(crate::store::StoreError::NotFound { .. }) if attempt < 4 => {
59                let backoff_ms = u64::from(5 * (attempt + 1));
60                tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
61                attempt += 1;
62            }
63            Err(e) => return Err(e),
64        }
65    }
66}
67
68/// v0.7.0 L5 — fire the LLM `auto_tag` hook for a freshly-built memory.
69///
70/// Returns the list of LLM-generated tags (capped at
71/// [`AUTO_TAG_MAX_TAGS`]) when every gate is satisfied:
72///   - The daemon's configured [`crate::config::FeatureTier`] declares
73///     an `llm_model` (the smart / autonomous tier capability —
74///     `tier_config.llm_model.is_some()`).
75///   - The operator did NOT pre-populate `tags` on the request
76///     (auto-tag never overwrites operator-supplied tags).
77///   - The content is at least [`AUTO_TAG_MIN_CONTENT_LEN`] chars
78///     (too-short content has no useful taggable signal).
79///   - The namespace is not internal / system (starts with `_`) —
80///     matches MCP's `handle_store` skip at `crate::mcp::handle_store` (skip-arm).
81///   - An LLM client is wired on `AppState` and the Ollama endpoint
82///     is reachable.
83///
84/// On any LLM error the function returns `Vec::new()` and logs a
85/// `tracing::warn!` — auto_tag is a soft hook and a failure must not
86/// fail the store (mirrors MCP `handle_store` at `crate::mcp::handle_store` (detect_contradiction block)).
87///
88/// The blocking Ollama call is wrapped in `tokio::task::spawn_blocking`
89/// to keep the async runtime healthy under load — matches the embedder
90/// pattern at `src/daemon_runtime.rs:1182`.
91pub(crate) async fn maybe_auto_tag(
92    app: &AppState,
93    title: &str,
94    content: &str,
95    operator_tags: &[String],
96    namespace: &str,
97) -> Vec<String> {
98    if !operator_tags.is_empty() {
99        return Vec::new();
100    }
101    if content.len() < AUTO_TAG_MIN_CONTENT_LEN {
102        return Vec::new();
103    }
104    if namespace.starts_with('_') {
105        return Vec::new();
106    }
107    if app.tier_config.llm_model.is_none() {
108        return Vec::new();
109    }
110    let llm_arc = app.llm.clone();
111    if llm_arc.is_none() {
112        return Vec::new();
113    }
114    // v0.7.0 L15 — when the operator has configured a dedicated tag
115    // model (`auto_tag_model = "..."` in config.toml), pass it through
116    // so the call hits the fast structured-output model instead of the
117    // reasoning-tier llm_model. Closes the NHI-D-autotag-empty finding
118    // where Gemma 4 thinking-mode would generate 400+ tokens for a
119    // 5-tag list and hit the 30s tail latency.
120    let auto_tag_model = app.auto_tag_model.as_ref().clone();
121    let title_owned = title.to_string();
122    let content_owned = content.to_string();
123    let llm_timeout = app.llm_call_timeout;
124    // H8 (v0.7.0 round-2) — bound the Ollama call by the configured
125    // per-LLM-call timeout (default 30s). On timeout we degrade to the
126    // LLM-absent fallback (empty tags) — same shape the keyword /
127    // semantic tiers already return when no LLM is wired (L5/L7).
128    // PERF-9 (v0.7.0 FX-C1, 2026-05-26) — direct async call. Pre-PERF-9
129    // this hopped through `spawn_blocking` because `OllamaClient::auto_tag`
130    // was synchronous-`reqwest::blocking` underneath; now it's
131    // `reqwest::Client` async, so we drive `auto_tag_async` inline and
132    // let `tokio::time::timeout` bound it without an extra thread hop.
133    let join = tokio::time::timeout(llm_timeout, async move {
134        let Some(llm) = llm_arc.as_ref() else {
135            return Ok::<Vec<String>, anyhow::Error>(Vec::new());
136        };
137        llm.auto_tag_async(&title_owned, &content_owned, auto_tag_model.as_deref())
138            .await
139    })
140    .await;
141    match join {
142        Ok(Ok(tags)) => tags.into_iter().take(AUTO_TAG_MAX_TAGS).collect(),
143        Ok(Err(e)) => {
144            tracing::warn!("L5: auto_tag hook failed: {e}");
145            Vec::new()
146        }
147        Err(_) => {
148            tracing::warn!(
149                "H8: LLM call (auto_tag) exceeded {}s timeout — falling back to no tags",
150                llm_timeout.as_secs()
151            );
152            Vec::new()
153        }
154    }
155}
156
157/// v0.7.0 (issue #519) — same-namespace conflict probe fired during
158/// `create_memory`. Mirrors the MCP `handle_store` autonomy hook's
159/// `detect_contradiction` loop (`crate::mcp::handle_store` (detect_contradiction loop)) but lives on the
160/// HTTP path so a smart/autonomous-tier daemon surfaces conflicts in the
161/// 201 response without requiring the caller to follow up with a manual
162/// `memory_detect_contradiction`.
163///
164/// Gating layers (any false → returns empty):
165///   1. `request_override`:
166///       `Some(true)`  → force-on regardless of `autonomous_hooks`
167///       `Some(false)` → force-off regardless of `autonomous_hooks`
168///       `None`        → defer to `autonomous_hooks`
169///   2. tier — only smart/autonomous (`tier_config.llm_model.is_some()`)
170///   3. LLM client wired (`app.llm`)
171///   4. content ≥ 50 chars (matches `AUTO_TAG_MIN_CONTENT_LEN`)
172///   5. namespace not `_*` (internal)
173///
174/// The probe is best-effort: any LLM error or timeout returns an empty
175/// vec — never fails the parent store. Bounded by the H8 per-LLM-call
176/// timeout (default 30s) the same way `maybe_auto_tag` is.
177//
178// v0.7.0 (round-2) — call sites for this helper are still being
179// wired in the create_memory hot path; the function is staged for
180// the next round so we silence the dead-code warning rather than
181// rip out the implementation. Tracked in issue #519.
182#[allow(dead_code)]
183async fn maybe_detect_conflicts(
184    app: &AppState,
185    title: &str,
186    content: &str,
187    namespace: &str,
188    request_override: Option<bool>,
189) -> Vec<ConflictReport> {
190    let enabled = match request_override {
191        Some(b) => b,
192        None => app.autonomous_hooks,
193    };
194    if !enabled
195        || content.len() < AUTO_TAG_MIN_CONTENT_LEN
196        || namespace.starts_with('_')
197        || app.tier_config.llm_model.is_none()
198    {
199        return Vec::new();
200    }
201    let llm_arc = app.llm.clone();
202    if llm_arc.is_none() {
203        return Vec::new();
204    }
205
206    // Pull same-namespace candidates that could contradict the new memory.
207    // Cap at 8 to bound LLM cost (8 × 30s worst-case = 4 min if every probe
208    // tail-times-out; in practice most return in 0.7s on gemma3:4b).
209    let candidates: Vec<(String, String, String)> =
210        match fetch_namespace_candidates(app, namespace, title, 8).await {
211            Ok(v) => v,
212            Err(e) => {
213                tracing::warn!("L?: maybe_detect_conflicts candidate fetch failed: {e}");
214                return Vec::new();
215            }
216        };
217
218    let llm_timeout = app.llm_call_timeout;
219    let new_content = content.to_string();
220    let mut out: Vec<ConflictReport> = Vec::new();
221    for (cand_id, cand_title, cand_content) in candidates {
222        let llm_arc_cl = llm_arc.clone();
223        let cand_content_cl = cand_content.clone();
224        let new_content_cl = new_content.clone();
225        // PERF-9 (v0.7.0 FX-C1) — direct async detect_contradiction.
226        let join = tokio::time::timeout(llm_timeout, async move {
227            let Some(llm) = llm_arc_cl.as_ref() else {
228                return Ok::<bool, anyhow::Error>(false);
229            };
230            llm.detect_contradiction_async(&new_content_cl, &cand_content_cl)
231                .await
232        })
233        .await;
234        match join {
235            Ok(Ok(true)) => out.push(ConflictReport {
236                id: cand_id,
237                title: cand_title,
238                suggested_merge: None,
239            }),
240            Ok(Ok(false)) => {}
241            Ok(Err(e)) => tracing::warn!("detect_contradiction LLM error for {cand_id}: {e}"),
242            Err(_) => tracing::warn!(
243                "H8: LLM call (detect_contradiction) exceeded {}s timeout for {cand_id} — skipping",
244                llm_timeout.as_secs()
245            ),
246        }
247    }
248    out
249}
250
251/// Fetch up to `limit` same-namespace memories whose title is NOT byte-equal
252/// to the incoming title (we want potentially-contradictory siblings, not
253/// the row that an UPSERT would target). Routes through the active storage
254/// backend.
255//
256// v0.7.0 (round-2) — only used by the staged-in `maybe_detect_conflicts`
257// helper above; silence dead_code under pedantic until #519 wires the
258// call site through create_memory.
259#[allow(dead_code)]
260async fn fetch_namespace_candidates(
261    app: &AppState,
262    namespace: &str,
263    new_title: &str,
264    limit: usize,
265) -> Result<Vec<(String, String, String)>, String> {
266    #[cfg(feature = "sal")]
267    if matches!(app.storage_backend, StorageBackend::Postgres) {
268        // v0.7.0 ship-hardening (2026-05-19): use for_admin so the
269        // duplicate-candidate lookup doesn't get scope=private
270        // filtered. The check_duplicate handler queries a namespace
271        // the caller is about to write into; pre-write candidate
272        // resolution is system-internal, not a user-visible read,
273        // and we want the candidate pool to surface every memory in
274        // the namespace regardless of who originally authored it.
275        let ctx =
276            crate::store::CallerContext::for_admin(crate::identity::sentinels::AI_HTTP_INTERNAL);
277        let filter = crate::store::Filter {
278            namespace: Some(namespace.to_string()),
279            limit: limit + 1,
280            ..crate::store::Filter::default()
281        };
282        let mems = app
283            .store
284            .list(&ctx, &filter)
285            .await
286            .map_err(|e| e.to_string())?;
287        return Ok(mems
288            .into_iter()
289            .filter(|m| m.title != new_title)
290            .take(limit)
291            .map(|m| (m.id, m.title, m.content))
292            .collect());
293    }
294    let lock = app.db.lock().await;
295    let mems = db::list(
296        &lock.0,
297        Some(namespace),
298        None,
299        limit + 1,
300        0,
301        None,
302        None,
303        None,
304        None,
305        None,
306    )
307    .map_err(|e| e.to_string())?;
308    Ok(mems
309        .into_iter()
310        .filter(|m| m.title != new_title)
311        .take(limit)
312        .map(|m| (m.id, m.title, m.content))
313        .collect())
314}
315
316/// v0.7.0 (issue #519) — a single same-namespace memory the LLM flagged as
317/// contradictory with the incoming row. Surfaced in the create_memory
318/// response under `conflicts: [...]` when proactive detection ran.
319#[derive(Debug, Clone, serde::Serialize)]
320pub struct ConflictReport {
321    pub id: String,
322    pub title: String,
323    /// LLM-proposed merged content. Future expansion (#519 §"suggested
324    /// merge"). For v0.7.0 ship-scope this is left `None`; the caller can
325    /// follow up with `memory_consolidate` using the reported ids. The
326    /// field reserves the wire shape so callers can branch on it now.
327    pub suggested_merge: Option<String>,
328}
329
330// v0.7.0 issue #897 — Coverage regression on the post-Wave-1-split
331// `src/handlers/http.rs` shim. Path-A test additions: directly
332// exercise the gate ladders + sqlite-branch traversal of the three
333// helpers that live in this file (`maybe_auto_tag`,
334// `maybe_detect_conflicts`, `fetch_namespace_candidates`). The
335// `#[cfg(test)]` gating keeps these out of the production binary
336// — pure test addition, no production behavior change.
337#[cfg(test)]
338#[allow(clippy::too_many_lines)]
339mod cov897_tests {
340    use super::{
341        AUTO_TAG_MIN_CONTENT_LEN, ConflictReport, fetch_namespace_candidates, maybe_auto_tag,
342        maybe_detect_conflicts,
343    };
344    use crate::config::{FeatureTier, ResolvedScoring, ResolvedTtl};
345    use crate::handlers::{AppState, Db, StorageBackend};
346    use crate::models::{Memory, Tier};
347    use chrono::Utc;
348    use std::sync::Arc;
349    use tokio::sync::{Mutex, RwLock};
350    use uuid::Uuid;
351
352    fn build_app(tier: FeatureTier, autonomous: bool) -> (AppState, tempfile::NamedTempFile) {
353        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
354        let path = tmp.path().to_path_buf();
355        let _ = crate::db::open(&path).expect("db::open");
356        let conn = crate::db::open(&path).expect("reopen");
357        let db: Db = Arc::new(Mutex::new((
358            conn,
359            path.clone(),
360            ResolvedTtl::default(),
361            true,
362        )));
363        #[cfg(feature = "sal")]
364        let store: Arc<dyn crate::store::MemoryStore> =
365            Arc::new(crate::store::sqlite::SqliteStore::open(&path).expect("open SqliteStore"));
366        let app = AppState {
367            db,
368            embedder: Arc::new(None),
369            vector_index: Arc::new(Mutex::new(None)),
370            federation: Arc::new(None),
371            tier_config: Arc::new(tier.config()),
372            scoring: Arc::new(ResolvedScoring::default()),
373            profile: Arc::new(crate::profile::Profile::core()),
374            mcp_config: Arc::new(None),
375            active_keypair: Arc::new(None),
376            family_embeddings: Arc::new(RwLock::new(Some(Vec::new()))),
377            storage_backend: StorageBackend::Sqlite,
378            #[cfg(feature = "sal")]
379            store,
380            llm: Arc::new(None),
381            auto_tag_model: Arc::new(None),
382            llm_call_timeout: std::time::Duration::from_secs(30),
383            replay_cache: Arc::new(crate::identity::replay::ReplayCache::default()),
384            verify_require_nonce: false,
385            federation_nonce_cache: Arc::new(
386                crate::identity::replay::FederationNonceCache::default(),
387            ),
388            autonomous_hooks: autonomous,
389            recall_scope: Arc::new(None),
390            deferred_audit_queue: Arc::new(None),
391            admin_agent_ids: Arc::new(Vec::new()),
392            rule_cache: std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new()),
393            resolved_models: std::sync::Arc::new(crate::config::ResolvedModels::default()),
394            runtime: crate::runtime_context::RuntimeContext::global_arc(),
395            max_page_size: crate::handlers::MAX_BULK_SIZE,
396        };
397        (app, tmp)
398    }
399
400    fn seed_memory(app: &AppState, namespace: &str, title: &str, content: &str) {
401        let now = Utc::now().to_rfc3339();
402        let mem = Memory {
403            id: Uuid::new_v4().to_string(),
404            title: title.to_string(),
405            content: content.to_string(),
406            namespace: namespace.to_string(),
407            tier: Tier::Mid,
408            created_at: now.clone(),
409            updated_at: now,
410            ..Default::default()
411        };
412        let lock = app.db.try_lock().expect("uncontended lock for seed");
413        crate::db::insert(&lock.0, &mem).expect("insert");
414    }
415
416    // ---- maybe_auto_tag: the llm-arc fast-path on a Smart-tier app -----
417    //
418    // The lib-tier `maybe_auto_tag_gate_matrix_l5` test already covers
419    // the operator-tags / short-content / internal-namespace / no-llm-
420    // model branches. This case completes the gate ladder: Smart tier
421    // sets `tier_config.llm_model = Some(...)`, the caller passes
422    // permissive args (long content, no tags, public namespace), but
423    // `app.llm = Arc::new(None)` — so the function must short-circuit
424    // at the `llm_arc.is_none()` check rather than fall through to
425    // the spawn_blocking path.
426    #[tokio::test]
427    async fn cov897_maybe_auto_tag_smart_tier_no_llm_arc_short_circuits() {
428        let (app, _tmp) = build_app(FeatureTier::Smart, false);
429        let r = maybe_auto_tag(
430            &app,
431            "title",
432            &"x".repeat(AUTO_TAG_MIN_CONTENT_LEN + 10),
433            &[],
434            "public-ns",
435        )
436        .await;
437        assert!(
438            r.is_empty(),
439            "Smart tier + llm=None must short-circuit, got {r:?}"
440        );
441    }
442
443    // ---- maybe_detect_conflicts: full gate-ladder coverage -------------
444
445    #[tokio::test]
446    async fn cov897_detect_conflicts_disabled_by_default_returns_empty() {
447        // autonomous_hooks=false + no per-request override → disabled.
448        let (app, _tmp) = build_app(FeatureTier::Smart, false);
449        let r = maybe_detect_conflicts(
450            &app,
451            "t",
452            &"x".repeat(AUTO_TAG_MIN_CONTENT_LEN + 10),
453            "ns",
454            None,
455        )
456        .await;
457        assert!(r.is_empty(), "disabled-by-config returns empty");
458    }
459
460    #[tokio::test]
461    async fn cov897_detect_conflicts_request_override_false_forces_off() {
462        // autonomous_hooks=true would normally enable; request override
463        // Some(false) must force-off.
464        let (app, _tmp) = build_app(FeatureTier::Smart, true);
465        let r = maybe_detect_conflicts(
466            &app,
467            "t",
468            &"x".repeat(AUTO_TAG_MIN_CONTENT_LEN + 10),
469            "ns",
470            Some(false),
471        )
472        .await;
473        assert!(r.is_empty(), "override=Some(false) returns empty");
474    }
475
476    #[tokio::test]
477    async fn cov897_detect_conflicts_short_content_returns_empty() {
478        // Override forces enabled, but content is below 50 chars.
479        let (app, _tmp) = build_app(FeatureTier::Smart, false);
480        let r = maybe_detect_conflicts(&app, "t", "short", "ns", Some(true)).await;
481        assert!(r.is_empty(), "short content returns empty");
482    }
483
484    #[tokio::test]
485    async fn cov897_detect_conflicts_internal_namespace_returns_empty() {
486        let (app, _tmp) = build_app(FeatureTier::Smart, false);
487        let r = maybe_detect_conflicts(
488            &app,
489            "t",
490            &"x".repeat(AUTO_TAG_MIN_CONTENT_LEN + 10),
491            "_internal",
492            Some(true),
493        )
494        .await;
495        assert!(r.is_empty(), "internal namespace returns empty");
496    }
497
498    #[tokio::test]
499    async fn cov897_detect_conflicts_no_llm_model_returns_empty() {
500        // Keyword tier has `llm_model = None` — gate ladder line 199.
501        let (app, _tmp) = build_app(FeatureTier::Keyword, false);
502        let r = maybe_detect_conflicts(
503            &app,
504            "t",
505            &"x".repeat(AUTO_TAG_MIN_CONTENT_LEN + 10),
506            "ns",
507            Some(true),
508        )
509        .await;
510        assert!(r.is_empty(), "no llm_model returns empty");
511    }
512
513    #[tokio::test]
514    async fn cov897_detect_conflicts_smart_tier_no_llm_arc_returns_empty() {
515        // Smart tier has llm_model=Some, but app.llm=None → line 204-206.
516        let (app, _tmp) = build_app(FeatureTier::Smart, false);
517        let r = maybe_detect_conflicts(
518            &app,
519            "t",
520            &"x".repeat(AUTO_TAG_MIN_CONTENT_LEN + 10),
521            "ns",
522            Some(true),
523        )
524        .await;
525        assert!(r.is_empty(), "Smart tier + llm=None returns empty");
526    }
527
528    // ---- fetch_namespace_candidates: sqlite-branch traversal -----------
529
530    #[tokio::test]
531    async fn cov897_fetch_candidates_empty_namespace_returns_empty() {
532        // Empty DB → empty candidate set; exercises the sqlite branch
533        // (lines 291-310) cleanly without hitting any candidates.
534        let (app, _tmp) = build_app(FeatureTier::Keyword, false);
535        let out = fetch_namespace_candidates(&app, "empty-ns", "new-title", 8)
536            .await
537            .expect("sqlite list succeeds on empty db");
538        assert!(out.is_empty(), "empty namespace returns no candidates");
539    }
540
541    #[tokio::test]
542    async fn cov897_fetch_candidates_filters_byte_equal_title() {
543        // Seed three rows in `ns-cand`; the function must return rows
544        // whose title is NOT byte-equal to `new_title`. With three
545        // seeded titles ["alpha", "beta", "gamma"] and new_title="beta"
546        // we expect exactly ["alpha", "gamma"].
547        let (app, _tmp) = build_app(FeatureTier::Keyword, false);
548        seed_memory(&app, "ns-cand", "alpha", "content-alpha");
549        seed_memory(&app, "ns-cand", "beta", "content-beta");
550        seed_memory(&app, "ns-cand", "gamma", "content-gamma");
551        let out = fetch_namespace_candidates(&app, "ns-cand", "beta", 8)
552            .await
553            .expect("sqlite list succeeds");
554        let titles: Vec<&str> = out.iter().map(|(_, t, _)| t.as_str()).collect();
555        assert_eq!(out.len(), 2, "filters byte-equal title, got {titles:?}");
556        assert!(titles.contains(&"alpha"), "alpha present in {titles:?}");
557        assert!(titles.contains(&"gamma"), "gamma present in {titles:?}");
558        assert!(!titles.contains(&"beta"), "beta filtered from {titles:?}");
559    }
560
561    #[tokio::test]
562    async fn cov897_fetch_candidates_honors_limit() {
563        // Seed 5 rows; ask for limit=2 — internal cap is limit+1=3
564        // candidates pulled, then post-filter `.take(limit)`. With a
565        // distinct new_title (no byte-equal match), `.take(2)` yields
566        // exactly 2 rows.
567        let (app, _tmp) = build_app(FeatureTier::Keyword, false);
568        for i in 0..5 {
569            seed_memory(
570                &app,
571                "ns-limit",
572                &format!("title-{i}"),
573                &format!("content-{i}"),
574            );
575        }
576        let out = fetch_namespace_candidates(&app, "ns-limit", "no-match", 2)
577            .await
578            .expect("sqlite list succeeds");
579        assert_eq!(out.len(), 2, "limit honored");
580    }
581
582    // ---- ConflictReport: pinned wire shape -----------------------------
583    //
584    // The struct lands in the create_memory response envelope under
585    // `conflicts: [...]`; pin its serialized shape so a future refactor
586    // doesn't silently rename a wire field.
587    #[test]
588    fn cov897_conflict_report_serializes_to_pinned_wire_shape() {
589        let r = ConflictReport {
590            id: "mem-id-123".to_string(),
591            title: "conflicting title".to_string(),
592            suggested_merge: None,
593        };
594        let v = serde_json::to_value(&r).expect("serialize");
595        assert_eq!(v["id"], "mem-id-123");
596        assert_eq!(v["title"], "conflicting title");
597        assert!(
598            v[crate::models::field_names::SUGGESTED_MERGE].is_null(),
599            "None ⇒ null on the wire"
600        );
601    }
602}