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}