Skip to main content

ai_memory/
handlers.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use axum::{
5    Json,
6    extract::{FromRef, Path, Query, Request, State},
7    http::{HeaderMap, StatusCode},
8    middleware::Next,
9    response::IntoResponse,
10};
11use chrono::{Duration, Utc};
12use serde::Deserialize;
13use serde_json::json;
14use std::sync::Arc;
15use tokio::sync::Mutex;
16use uuid::Uuid;
17
18use crate::config::{ResolvedTtl, TierConfig};
19use crate::db;
20use crate::embeddings::Embedder;
21use crate::hnsw::VectorIndex;
22use crate::models::{
23    CreateMemory, ForgetQuery, LinkBody, ListQuery, Memory, MemoryLink, RecallBody, RecallQuery,
24    RegisterAgentBody, SearchQuery, Tier, UpdateMemory,
25};
26use crate::validate;
27
28pub type Db = Arc<Mutex<(rusqlite::Connection, std::path::PathBuf, ResolvedTtl, bool)>>;
29
30/// Composite daemon state (issue #219/v0.7 prep).
31///
32/// Previously the Axum router held only `Db`. Closing the HTTP embedding gap
33/// (semantic recall silently missed HTTP-stored memories because the daemon
34/// never generated embeddings) requires the embedder and the in-memory HNSW
35/// index to be reachable from write handlers. We introduce `AppState` and
36/// use `FromRef` so every existing `State<Db>` handler keeps working
37/// unchanged — only the write paths opt into `State<AppState>` to pick up
38/// the embedder and vector index.
39#[derive(Clone)]
40pub struct AppState {
41    pub db: Db,
42    pub embedder: Arc<Option<Embedder>>,
43    pub vector_index: Arc<Mutex<Option<VectorIndex>>>,
44    /// v0.7 federation config — `Some` when `--quorum-writes N` +
45    /// `--quorum-peers` are configured at serve time. Writes fan out
46    /// to peers via `FederationConfig::broadcast_store_quorum` when
47    /// this is `Some`.
48    pub federation: Arc<Option<crate::federation::FederationConfig>>,
49    /// Resolved [`TierConfig`] for this daemon. Exposed so HTTP
50    /// endpoints that mirror MCP tools (notably `/capabilities`) can
51    /// reuse the MCP-side report builder without re-parsing config.
52    pub tier_config: Arc<TierConfig>,
53    /// v0.6.2 (S18): resolved recall scoring config — tier half-lives,
54    /// legacy-scoring toggle. Exposed so `recall_memories_get` /
55    /// `recall_memories_post` can call `db::recall_hybrid` (semantic
56    /// blend) when the embedder is loaded, mirroring how the MCP
57    /// `memory_recall` handler already wires it (src/mcp.rs:1157).
58    /// Prior to this, HTTP recall was keyword-only regardless of
59    /// embedder availability — scenario-18 surfaced the gap.
60    pub scoring: Arc<crate::config::ResolvedScoring>,
61}
62
63impl FromRef<AppState> for Db {
64    fn from_ref(app: &AppState) -> Self {
65        app.db.clone()
66    }
67}
68
69const MAX_BULK_SIZE: usize = 1000;
70
71/// v0.6.2 (S40): maximum number of per-row `broadcast_store_quorum` fanouts
72/// in flight at once during `bulk_create`. Replaces the prior sequential
73/// for-loop (which paid 100ms × N rows of wall time and blew past the
74/// testbook's 20s settle on N=500) with bounded concurrency. The bound
75/// balances speedup against peer-side `SQLite` Mutex contention and the
76/// leader-side reqwest connection-pool / ephemeral-port envelope. See the
77/// comment above the loop in `bulk_create` for the full rationale.
78const BULK_FANOUT_CONCURRENCY: usize = 8;
79
80/// Shared state for API key authentication middleware.
81#[derive(Clone)]
82pub struct ApiKeyState {
83    pub key: Option<String>,
84}
85
86/// Constant-time byte-slice equality. Doesn't short-circuit on the
87/// Percent-decode a URL-encoded query value in place. Invalid `%XX`
88/// escapes are passed through verbatim (lossy). Ultrareview #337.
89#[inline]
90fn percent_decode_lossy(input: &str) -> String {
91    let bytes = input.as_bytes();
92    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
93    let mut i = 0;
94    while i < bytes.len() {
95        if bytes[i] == b'%' && i + 2 < bytes.len() {
96            let h = (bytes[i + 1] as char).to_digit(16);
97            let l = (bytes[i + 2] as char).to_digit(16);
98            if let (Some(h), Some(l)) = (h, l) {
99                // h and l are single hex digits (0..=15), so h*16 + l
100                // is always in 0..=255. Cast is lossless.
101                out.push(u8::try_from(h * 16 + l).unwrap_or(0));
102                i += 3;
103                continue;
104            }
105        }
106        out.push(bytes[i]);
107        i += 1;
108    }
109    String::from_utf8_lossy(&out).into_owned()
110}
111
112/// first mismatched byte, preventing timing-oracle leaks of secret
113/// material. Used for API-key comparison (#301 hardening item 3).
114#[inline]
115fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
116    if a.len() != b.len() {
117        return false;
118    }
119    let mut diff: u8 = 0;
120    for (x, y) in a.iter().zip(b.iter()) {
121        diff |= x ^ y;
122    }
123    diff == 0
124}
125
126/// Middleware: reject requests with 401 if `api_key` is configured and request
127/// doesn't provide a matching `X-API-Key` header or `?api_key=` query param.
128/// The `/api/v1/health` endpoint is exempt.
129pub async fn api_key_auth(
130    State(auth): State<ApiKeyState>,
131    req: Request,
132    next: Next,
133) -> impl IntoResponse {
134    let Some(ref expected) = auth.key else {
135        // No API key configured — allow all requests
136        return next.run(req).await.into_response();
137    };
138
139    // Exempt health endpoint
140    if req.uri().path() == "/api/v1/health" {
141        return next.run(req).await.into_response();
142    }
143
144    // Check X-API-Key header
145    if let Some(header_val) = req.headers().get("x-api-key")
146        && let Ok(val) = header_val.to_str()
147        && constant_time_eq(val.as_bytes(), expected.as_bytes())
148    {
149        return next.run(req).await.into_response();
150    }
151
152    // Check ?api_key= query param (ultrareview #337: URL-decode
153    // before comparison. A key with reserved chars like `+`, `%`,
154    // `&` must be percent-encoded by the caller per RFC 3986; the
155    // previous raw-compare path silently mismatched those keys and
156    // opened an encoded-bypass surface where a key containing `%2B`
157    // would compare against `%2B` rather than `+`, producing a
158    // different trust decision depending on caller quoting.)
159    if let Some(query) = req.uri().query() {
160        for pair in query.split('&') {
161            if let Some(val) = pair.strip_prefix("api_key=") {
162                let decoded = percent_decode_lossy(val);
163                if constant_time_eq(decoded.as_bytes(), expected.as_bytes()) {
164                    return next.run(req).await.into_response();
165                }
166            }
167        }
168    }
169
170    (
171        StatusCode::UNAUTHORIZED,
172        Json(json!({"error": "missing or invalid API key"})),
173    )
174        .into_response()
175}
176
177pub async fn health(State(app): State<AppState>) -> impl IntoResponse {
178    let lock = app.db.lock().await;
179    let ok = db::health_check(&lock.0).unwrap_or(false);
180    drop(lock);
181    let embedder_ready = app.embedder.as_ref().is_some();
182    let federation_enabled = app.federation.as_ref().is_some();
183    let code = if ok {
184        StatusCode::OK
185    } else {
186        StatusCode::SERVICE_UNAVAILABLE
187    };
188    // v0.6.2 (#327): expose embedder status so operators can tell from
189    // /health alone whether semantic recall is wired up on this node.
190    (
191        code,
192        Json(json!({
193            "status": if ok { "ok" } else { "error" },
194            "service": "ai-memory",
195            "version": env!("CARGO_PKG_VERSION"),
196            "embedder_ready": embedder_ready,
197            "federation_enabled": federation_enabled,
198        })),
199    )
200        .into_response()
201}
202
203/// v0.6.0.0 — Prometheus scrape endpoint. Refreshes gauge samples
204/// (`ai_memory_memories`) against the current DB before rendering so
205/// scrapers see up-to-date counts without needing a background refresh
206/// task.
207pub async fn prometheus_metrics(State(state): State<Db>) -> impl IntoResponse {
208    {
209        let lock = state.lock().await;
210        if let Ok(stats) = db::stats(&lock.0, &lock.1) {
211            crate::metrics::registry()
212                .memories_gauge
213                .set(stats.total.try_into().unwrap_or(i64::MAX));
214        }
215    }
216    let body = crate::metrics::render();
217    (
218        StatusCode::OK,
219        [(
220            axum::http::header::CONTENT_TYPE,
221            "text/plain; version=0.0.4; charset=utf-8",
222        )],
223        body,
224    )
225        .into_response()
226}
227
228#[allow(clippy::too_many_lines)]
229pub async fn create_memory(
230    State(app): State<AppState>,
231    headers: HeaderMap,
232    Json(body): Json<CreateMemory>,
233) -> impl IntoResponse {
234    let state = app.db.clone();
235    if let Err(e) = validate::validate_create(&body) {
236        return (
237            StatusCode::BAD_REQUEST,
238            Json(json!({"error": e.to_string()})),
239        )
240            .into_response();
241    }
242
243    // Resolve agent_id via the HTTP precedence chain (body → X-Agent-Id → per-request anonymous)
244    let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
245    let agent_id =
246        match crate::identity::resolve_http_agent_id(body.agent_id.as_deref(), header_agent_id) {
247            Ok(id) => id,
248            Err(e) => {
249                return (
250                    StatusCode::BAD_REQUEST,
251                    Json(json!({"error": format!("invalid agent_id: {e}")})),
252                )
253                    .into_response();
254            }
255        };
256    let mut metadata = body.metadata;
257    if let Some(obj) = metadata.as_object_mut() {
258        obj.insert("agent_id".to_string(), serde_json::Value::String(agent_id));
259    }
260    // #151 scope: validate + merge into metadata if supplied at the top level
261    // (inline metadata.scope still works; top-level is a shortcut)
262    if let Some(ref s) = body.scope {
263        if let Err(e) = validate::validate_scope(s) {
264            return (
265                StatusCode::BAD_REQUEST,
266                Json(json!({"error": e.to_string()})),
267            )
268                .into_response();
269        }
270        if let Some(obj) = metadata.as_object_mut() {
271            obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
272        }
273    }
274
275    // Issue #219: generate the embedding BEFORE taking the DB lock. Embedding
276    // (MiniLM ONNX / nomic via Ollama) is 10-200ms of work we do not want
277    // holding the single `Mutex<Connection>` on a multi-agent daemon.
278    let embedding_text = format!("{} {}", body.title, body.content);
279    let embedding: Option<Vec<f32>> =
280        app.embedder
281            .as_ref()
282            .as_ref()
283            .and_then(|emb| match emb.embed(&embedding_text) {
284                Ok(v) => Some(v),
285                Err(e) => {
286                    tracing::warn!("embedding generation failed: {e}");
287                    None
288                }
289            });
290
291    // v0.6.3.1 P2 (G6) — resolve `on_conflict` policy. HTTP defaults to
292    // 'error' (no legacy v1 backward-compat to honor); callers that want
293    // the v0.6.3 silent-merge behaviour must pass on_conflict='merge'.
294    let on_conflict_mode = body.on_conflict.as_deref().unwrap_or("error");
295    if !matches!(on_conflict_mode, "error" | "merge" | "version") {
296        return (
297            StatusCode::BAD_REQUEST,
298            Json(json!({
299                "error": format!(
300                    "invalid on_conflict '{on_conflict_mode}' (expected error|merge|version)"
301                )
302            })),
303        )
304            .into_response();
305    }
306
307    let now = Utc::now();
308    let lock = state.lock().await;
309    let expires_at = body.expires_at.or_else(|| {
310        body.ttl_secs
311            .or(lock.2.ttl_for_tier(&body.tier))
312            .map(|s| (now + Duration::seconds(s)).to_rfc3339())
313    });
314
315    // v0.6.3.1 P2 (G6) — apply the conflict policy before building the
316    // canonical row. Mirror MCP handle_store: 'error' returns 409 with a
317    // typed payload; 'version' rewrites the title to a free suffix;
318    // 'merge' falls through to db::insert which keeps the legacy
319    // INSERT...ON CONFLICT upsert.
320    let resolved_title = match on_conflict_mode {
321        "error" => match db::find_by_title_namespace(&lock.0, &body.title, &body.namespace) {
322            Ok(Some(existing_id)) => {
323                return (
324                    StatusCode::CONFLICT,
325                    Json(json!({
326                        "code": "CONFLICT",
327                        "error": format!(
328                            "memory with title '{}' already exists in namespace '{}'",
329                            body.title, body.namespace
330                        ),
331                        "existing_id": existing_id,
332                    })),
333                )
334                    .into_response();
335            }
336            Ok(None) => body.title.clone(),
337            Err(e) => {
338                tracing::error!("on_conflict lookup failed: {e}");
339                return (
340                    StatusCode::INTERNAL_SERVER_ERROR,
341                    Json(json!({"error": "conflict check failed"})),
342                )
343                    .into_response();
344            }
345        },
346        "version" => match db::next_versioned_title(&lock.0, &body.title, &body.namespace) {
347            Ok(t) => t,
348            Err(e) => {
349                tracing::error!("on_conflict=version failed: {e}");
350                return (
351                    StatusCode::INTERNAL_SERVER_ERROR,
352                    Json(json!({"error": "could not pick a versioned title"})),
353                )
354                    .into_response();
355            }
356        },
357        _ => body.title.clone(),
358    };
359
360    let mem = Memory {
361        id: Uuid::new_v4().to_string(),
362        tier: body.tier,
363        namespace: body.namespace,
364        title: resolved_title,
365        content: body.content,
366        tags: body.tags,
367        priority: body.priority.clamp(1, 10),
368        confidence: body.confidence.clamp(0.0, 1.0),
369        source: body.source,
370        access_count: 0,
371        created_at: now.to_rfc3339(),
372        updated_at: now.to_rfc3339(),
373        last_accessed_at: None,
374        expires_at,
375        metadata,
376    };
377
378    // Task 1.9: governance enforcement (store-side).
379    {
380        use crate::models::{GovernanceDecision, GovernedAction};
381        let agent_for_gov = mem
382            .metadata
383            .get("agent_id")
384            .and_then(|v| v.as_str())
385            .unwrap_or_default()
386            .to_string();
387        let payload = serde_json::to_value(&mem).unwrap_or_default();
388        match db::enforce_governance(
389            &lock.0,
390            GovernedAction::Store,
391            &mem.namespace,
392            &agent_for_gov,
393            None,
394            None,
395            &payload,
396        ) {
397            Ok(GovernanceDecision::Allow) => {}
398            Ok(GovernanceDecision::Deny(reason)) => {
399                return (
400                    StatusCode::FORBIDDEN,
401                    Json(json!({"error": format!("store denied by governance: {reason}")})),
402                )
403                    .into_response();
404            }
405            Ok(GovernanceDecision::Pending(pending_id)) => {
406                // v0.6.2 (S34): fan out the new pending row so peers can
407                // approve / reject / list it. Load the canonical row we
408                // just inserted and broadcast before responding.
409                let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
410                let namespace = mem.namespace.clone();
411                drop(lock);
412                if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
413                    match crate::federation::broadcast_pending_quorum(fed, pa).await {
414                        Ok(tracker) => {
415                            if let Err(err) = crate::federation::finalise_quorum(&tracker) {
416                                let payload =
417                                    crate::federation::QuorumNotMetPayload::from_err(&err);
418                                return (
419                                    StatusCode::SERVICE_UNAVAILABLE,
420                                    [("Retry-After", "2")],
421                                    Json(serde_json::to_value(&payload).unwrap_or_default()),
422                                )
423                                    .into_response();
424                            }
425                        }
426                        Err(err) => {
427                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
428                            return (
429                                StatusCode::SERVICE_UNAVAILABLE,
430                                [("Retry-After", "2")],
431                                Json(serde_json::to_value(&payload).unwrap_or_default()),
432                            )
433                                .into_response();
434                        }
435                    }
436                }
437                return (
438                    StatusCode::ACCEPTED,
439                    Json(json!({
440                        "status": "pending",
441                        "pending_id": pending_id,
442                        "reason": "governance requires approval",
443                        "action": "store",
444                        "namespace": namespace,
445                    })),
446                )
447                    .into_response();
448            }
449            Err(e) => {
450                tracing::error!("governance error: {e}");
451                return (
452                    StatusCode::INTERNAL_SERVER_ERROR,
453                    Json(json!({"error": "governance check failed"})),
454                )
455                    .into_response();
456            }
457        }
458    }
459
460    // Check for contradictions
461    let contradictions =
462        db::find_contradictions(&lock.0, &mem.title, &mem.namespace).unwrap_or_default();
463    let contradiction_ids: Vec<String> = contradictions
464        .iter()
465        .filter(|c| c.id != mem.id)
466        .map(|c| c.id.clone())
467        .collect();
468
469    match db::insert(&lock.0, &mem) {
470        Ok(actual_id) => {
471            // Issue #219: persist the embedding and warm the HNSW index so
472            // semantic recall can find this memory. Previously the HTTP path
473            // stored the row but never called `set_embedding`, silently
474            // excluding every HTTP-authored memory from semantic search.
475            if let Some(ref vec) = embedding
476                && let Err(e) = db::set_embedding(&lock.0, &actual_id, vec)
477            {
478                tracing::warn!("failed to store embedding for {actual_id}: {e}");
479            }
480            // Drop the DB lock before taking the vector index lock.
481            drop(lock);
482            if let Some(vec) = embedding {
483                let mut idx_lock = app.vector_index.lock().await;
484                if let Some(idx) = idx_lock.as_mut() {
485                    idx.insert(actual_id.clone(), vec);
486                }
487            }
488            // #196: echo the resolved agent_id so callers don't need a follow-up get.
489            let resolved_agent_id = mem
490                .metadata
491                .get("agent_id")
492                .and_then(|v| v.as_str())
493                .map(str::to_string);
494            // PR-5 (issue #487): security audit trail for HTTP store.
495            crate::audit::emit(crate::audit::EventBuilder::new(
496                crate::audit::AuditAction::Store,
497                crate::audit::actor(
498                    resolved_agent_id.clone().unwrap_or_default(),
499                    "http_body",
500                    mem.metadata
501                        .get("scope")
502                        .and_then(|v| v.as_str())
503                        .map(str::to_string),
504                ),
505                crate::audit::target_memory(
506                    actual_id.clone(),
507                    mem.namespace.clone(),
508                    Some(mem.title.clone()),
509                    Some(mem.tier.to_string()),
510                    mem.metadata
511                        .get("scope")
512                        .and_then(|v| v.as_str())
513                        .map(str::to_string),
514                ),
515            ));
516            let mut response = json!({
517                "id": actual_id,
518                "tier": mem.tier,
519                "namespace": mem.namespace,
520                "title": mem.title,
521                "agent_id": resolved_agent_id,
522            });
523            if !contradiction_ids.is_empty() {
524                response["potential_contradictions"] = json!(contradiction_ids);
525            }
526            // v0.7 federation: fan out to peers when --quorum-writes is
527            // configured. The local commit already landed; if quorum
528            // is not met we return 503 but we do NOT roll back the
529            // local write — per ADR-0001, caller sees
530            // BackendUnavailable{quorum} and the sync-daemon's
531            // eventual-consistency loop catches straggling peers up.
532            if let Some(fed) = app.federation.as_ref() {
533                let mut mem_echo = mem.clone();
534                mem_echo.id = actual_id.clone();
535                match crate::federation::broadcast_store_quorum(fed, &mem_echo).await {
536                    Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
537                        Ok(got) => {
538                            response["quorum_acks"] = json!(got);
539                            return (StatusCode::CREATED, Json(response)).into_response();
540                        }
541                        Err(err) => {
542                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
543                            return (
544                                StatusCode::SERVICE_UNAVAILABLE,
545                                [("Retry-After", "2")],
546                                Json(serde_json::to_value(&payload).unwrap_or_default()),
547                            )
548                                .into_response();
549                        }
550                    },
551                    Err(err) => {
552                        let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
553                        return (
554                            StatusCode::SERVICE_UNAVAILABLE,
555                            [("Retry-After", "2")],
556                            Json(serde_json::to_value(&payload).unwrap_or_default()),
557                        )
558                            .into_response();
559                    }
560                }
561            }
562            (StatusCode::CREATED, Json(response)).into_response()
563        }
564        Err(e) => {
565            tracing::error!("handler error: {e}");
566            (
567                StatusCode::INTERNAL_SERVER_ERROR,
568                Json(json!({"error": "internal server error"})),
569            )
570                .into_response()
571        }
572    }
573}
574
575pub async fn register_agent(
576    State(app): State<AppState>,
577    Json(body): Json<RegisterAgentBody>,
578) -> impl IntoResponse {
579    if let Err(e) = validate::validate_agent_id(&body.agent_id) {
580        return (
581            StatusCode::BAD_REQUEST,
582            Json(json!({"error": e.to_string()})),
583        )
584            .into_response();
585    }
586    if let Err(e) = validate::validate_agent_type(&body.agent_type) {
587        return (
588            StatusCode::BAD_REQUEST,
589            Json(json!({"error": e.to_string()})),
590        )
591            .into_response();
592    }
593    let capabilities = body.capabilities.unwrap_or_default();
594    if let Err(e) = validate::validate_capabilities(&capabilities) {
595        return (
596            StatusCode::BAD_REQUEST,
597            Json(json!({"error": e.to_string()})),
598        )
599            .into_response();
600    }
601
602    let lock = app.db.lock().await;
603    let register_result =
604        db::register_agent(&lock.0, &body.agent_id, &body.agent_type, &capabilities);
605    // Read the persisted `_agents` row back so we can fan it out to peers.
606    // The cluster-wide S12 invariant is that an agent registered on node-1
607    // is visible on node-4 — which only holds when the `_agents` namespace
608    // replicates via `broadcast_store_quorum`.
609    let registered_mem = match &register_result {
610        Ok(id) => db::get(&lock.0, id).ok().flatten(),
611        Err(_) => None,
612    };
613    drop(lock);
614
615    match register_result {
616        Ok(id) => {
617            if let (Some(fed), Some(mem)) = (app.federation.as_ref(), registered_mem.as_ref()) {
618                match crate::federation::broadcast_store_quorum(fed, mem).await {
619                    Ok(tracker) => {
620                        if let Err(err) = crate::federation::finalise_quorum(&tracker) {
621                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
622                            return (
623                                StatusCode::SERVICE_UNAVAILABLE,
624                                [("Retry-After", "2")],
625                                Json(serde_json::to_value(&payload).unwrap_or_default()),
626                            )
627                                .into_response();
628                        }
629                    }
630                    Err(e) => {
631                        tracing::warn!("register_agent fanout error (local committed): {e:?}");
632                    }
633                }
634            }
635            (
636                StatusCode::CREATED,
637                Json(json!({
638                    "registered": true,
639                    "id": id,
640                    "agent_id": body.agent_id,
641                    "agent_type": body.agent_type,
642                    "capabilities": capabilities,
643                })),
644            )
645                .into_response()
646        }
647        Err(e) => {
648            tracing::error!("handler error: {e}");
649            (
650                StatusCode::INTERNAL_SERVER_ERROR,
651                Json(json!({"error": "internal server error"})),
652            )
653                .into_response()
654        }
655    }
656}
657
658// ---------------------------------------------------------------------------
659// Task 1.9 — pending_actions endpoints
660// ---------------------------------------------------------------------------
661
662#[derive(Deserialize)]
663pub struct PendingListQuery {
664    #[serde(default)]
665    pub status: Option<String>,
666    #[serde(default = "default_pending_limit")]
667    pub limit: Option<usize>,
668}
669
670#[allow(clippy::unnecessary_wraps)]
671fn default_pending_limit() -> Option<usize> {
672    Some(100)
673}
674
675pub async fn list_pending(
676    State(state): State<Db>,
677    Query(p): Query<PendingListQuery>,
678) -> impl IntoResponse {
679    let limit = p.limit.unwrap_or(100).min(1000);
680    let lock = state.lock().await;
681    match db::list_pending_actions(&lock.0, p.status.as_deref(), limit) {
682        Ok(items) => Json(json!({"count": items.len(), "pending": items})).into_response(),
683        Err(e) => {
684            tracing::error!("handler error: {e}");
685            (
686                StatusCode::INTERNAL_SERVER_ERROR,
687                Json(json!({"error": "internal server error"})),
688            )
689                .into_response()
690        }
691    }
692}
693
694#[allow(clippy::too_many_lines)]
695pub async fn approve_pending(
696    State(app): State<AppState>,
697    headers: HeaderMap,
698    Path(id): Path<String>,
699) -> impl IntoResponse {
700    use crate::db::ApproveOutcome;
701    use crate::models::PendingDecision;
702    let state = app.db.clone();
703    if let Err(e) = validate::validate_id(&id) {
704        return (
705            StatusCode::BAD_REQUEST,
706            Json(json!({"error": e.to_string()})),
707        )
708            .into_response();
709    }
710    let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
711    let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
712        Ok(a) => a,
713        Err(e) => {
714            return (
715                StatusCode::BAD_REQUEST,
716                Json(json!({"error": format!("invalid agent_id: {e}")})),
717            )
718                .into_response();
719        }
720    };
721    let lock = state.lock().await;
722    match db::approve_with_approver_type(&lock.0, &id, &agent_id) {
723        Ok(ApproveOutcome::Approved) => match db::execute_pending_action(&lock.0, &id) {
724            Ok(memory_id) => {
725                // v0.6.2 (S34): fan out the decision AND the resulting
726                // memory so approve on one node makes the governed write
727                // visible on every peer. Drop the DB lock before any
728                // outbound HTTP.
729                let produced_mem = memory_id
730                    .as_deref()
731                    .and_then(|mid| db::get(&lock.0, mid).ok().flatten());
732                drop(lock);
733                if let Some(fed) = app.federation.as_ref() {
734                    let decision = PendingDecision {
735                        id: id.clone(),
736                        approved: true,
737                        decider: agent_id.clone(),
738                    };
739                    match crate::federation::broadcast_pending_decision_quorum(fed, &decision).await
740                    {
741                        Ok(tracker) => {
742                            if let Err(err) = crate::federation::finalise_quorum(&tracker) {
743                                let payload =
744                                    crate::federation::QuorumNotMetPayload::from_err(&err);
745                                return (
746                                    StatusCode::SERVICE_UNAVAILABLE,
747                                    [("Retry-After", "2")],
748                                    Json(serde_json::to_value(&payload).unwrap_or_default()),
749                                )
750                                    .into_response();
751                            }
752                        }
753                        Err(err) => {
754                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
755                            return (
756                                StatusCode::SERVICE_UNAVAILABLE,
757                                [("Retry-After", "2")],
758                                Json(serde_json::to_value(&payload).unwrap_or_default()),
759                            )
760                                .into_response();
761                        }
762                    }
763                    // If approval produced a brand-new memory (store
764                    // path), also broadcast it so peers have the row.
765                    // delete / promote paths produce no new memory
766                    // (the pending payload carries memory_id).
767                    if let Some(ref mem) = produced_mem
768                        && let Some(resp) = fanout_or_503(&app, mem).await
769                    {
770                        return resp;
771                    }
772                }
773                Json(json!({
774                    "approved": true,
775                    "id": id,
776                    "decided_by": agent_id,
777                    "executed": true,
778                    "memory_id": memory_id,
779                }))
780                .into_response()
781            }
782            Err(e) => {
783                tracing::error!("execute pending error: {e}");
784                (
785                    StatusCode::INTERNAL_SERVER_ERROR,
786                    Json(json!({"error": "approved but execution failed"})),
787                )
788                    .into_response()
789            }
790        },
791        Ok(ApproveOutcome::Pending { votes, quorum }) => (
792            StatusCode::ACCEPTED,
793            Json(json!({
794                "approved": false,
795                "status": "pending",
796                "id": id,
797                "votes": votes,
798                "quorum": quorum,
799                "reason": "consensus threshold not yet reached",
800            })),
801        )
802            .into_response(),
803        Ok(ApproveOutcome::Rejected(reason)) => (
804            StatusCode::FORBIDDEN,
805            Json(json!({"error": format!("approve rejected: {reason}")})),
806        )
807            .into_response(),
808        Err(e) => {
809            tracing::error!("handler error: {e}");
810            (
811                StatusCode::INTERNAL_SERVER_ERROR,
812                Json(json!({"error": "internal server error"})),
813            )
814                .into_response()
815        }
816    }
817}
818
819pub async fn reject_pending(
820    State(app): State<AppState>,
821    headers: HeaderMap,
822    Path(id): Path<String>,
823) -> impl IntoResponse {
824    use crate::models::PendingDecision;
825    let state = app.db.clone();
826    if let Err(e) = validate::validate_id(&id) {
827        return (
828            StatusCode::BAD_REQUEST,
829            Json(json!({"error": e.to_string()})),
830        )
831            .into_response();
832    }
833    let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
834    let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
835        Ok(a) => a,
836        Err(e) => {
837            return (
838                StatusCode::BAD_REQUEST,
839                Json(json!({"error": format!("invalid agent_id: {e}")})),
840            )
841                .into_response();
842        }
843    };
844    let lock = state.lock().await;
845    match db::decide_pending_action(&lock.0, &id, false, &agent_id) {
846        Ok(true) => {
847            drop(lock);
848            // v0.6.2 (S34): fan out the reject so peers converge.
849            if let Some(fed) = app.federation.as_ref() {
850                let decision = PendingDecision {
851                    id: id.clone(),
852                    approved: false,
853                    decider: agent_id.clone(),
854                };
855                match crate::federation::broadcast_pending_decision_quorum(fed, &decision).await {
856                    Ok(tracker) => {
857                        if let Err(err) = crate::federation::finalise_quorum(&tracker) {
858                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
859                            return (
860                                StatusCode::SERVICE_UNAVAILABLE,
861                                [("Retry-After", "2")],
862                                Json(serde_json::to_value(&payload).unwrap_or_default()),
863                            )
864                                .into_response();
865                        }
866                    }
867                    Err(err) => {
868                        let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
869                        return (
870                            StatusCode::SERVICE_UNAVAILABLE,
871                            [("Retry-After", "2")],
872                            Json(serde_json::to_value(&payload).unwrap_or_default()),
873                        )
874                            .into_response();
875                    }
876                }
877            }
878            Json(json!({"rejected": true, "id": id, "decided_by": agent_id})).into_response()
879        }
880        Ok(false) => (
881            StatusCode::NOT_FOUND,
882            Json(json!({"error": "pending action not found or already decided"})),
883        )
884            .into_response(),
885        Err(e) => {
886            tracing::error!("handler error: {e}");
887            (
888                StatusCode::INTERNAL_SERVER_ERROR,
889                Json(json!({"error": "internal server error"})),
890            )
891                .into_response()
892        }
893    }
894}
895
896pub async fn list_agents(State(state): State<Db>) -> impl IntoResponse {
897    let lock = state.lock().await;
898    match db::list_agents(&lock.0) {
899        Ok(agents) => (
900            StatusCode::OK,
901            Json(json!({"count": agents.len(), "agents": agents})),
902        )
903            .into_response(),
904        Err(e) => {
905            tracing::error!("handler error: {e}");
906            (
907                StatusCode::INTERNAL_SERVER_ERROR,
908                Json(json!({"error": "internal server error"})),
909            )
910                .into_response()
911        }
912    }
913}
914
915pub async fn get_memory(State(state): State<Db>, Path(id): Path<String>) -> impl IntoResponse {
916    if let Err(e) = validate::validate_id(&id) {
917        return (
918            StatusCode::BAD_REQUEST,
919            Json(json!({"error": e.to_string()})),
920        )
921            .into_response();
922    }
923    let lock = state.lock().await;
924    match db::resolve_id(&lock.0, &id) {
925        Ok(Some(mem)) => {
926            let links = db::get_links(&lock.0, &mem.id).unwrap_or_default();
927            Json(json!({"memory": mem, "links": links})).into_response()
928        }
929        Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
930        Err(e) => {
931            let msg = e.to_string();
932            if msg.contains("ambiguous ID prefix") {
933                return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
934            }
935            tracing::error!("handler error: {e}");
936            (
937                StatusCode::INTERNAL_SERVER_ERROR,
938                Json(json!({"error": "internal server error"})),
939            )
940                .into_response()
941        }
942    }
943}
944
945#[allow(clippy::too_many_lines)]
946pub async fn update_memory(
947    State(app): State<AppState>,
948    Path(id): Path<String>,
949    Json(body): Json<UpdateMemory>,
950) -> impl IntoResponse {
951    let state = app.db.clone();
952    if let Err(e) = validate::validate_id(&id) {
953        return (
954            StatusCode::BAD_REQUEST,
955            Json(json!({"error": e.to_string()})),
956        )
957            .into_response();
958    }
959    if let Err(e) = validate::validate_update(&body) {
960        return (
961            StatusCode::BAD_REQUEST,
962            Json(json!({"error": e.to_string()})),
963        )
964            .into_response();
965    }
966    let lock = state.lock().await;
967    // Resolve prefix if exact ID not found
968    let resolved_id = match db::resolve_id(&lock.0, &id) {
969        Ok(Some(mem)) => mem.id,
970        Ok(None) => {
971            return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
972        }
973        Err(e) => {
974            let msg = e.to_string();
975            if msg.contains("ambiguous ID prefix") {
976                return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
977            }
978            tracing::error!("handler error: {e}");
979            return (
980                StatusCode::INTERNAL_SERVER_ERROR,
981                Json(json!({"error": "internal server error"})),
982            )
983                .into_response();
984        }
985    };
986    // Preserve existing agent_id when caller provides new metadata — provenance
987    // is immutable after first write (see NHI design in crate::identity).
988    let preserved_metadata = body.metadata.as_ref().map(|new_meta| {
989        let existing_meta = db::get(&lock.0, &resolved_id).ok().flatten().map_or_else(
990            || serde_json::Value::Object(serde_json::Map::new()),
991            |m| m.metadata,
992        );
993        crate::identity::preserve_agent_id(&existing_meta, new_meta)
994    });
995    match db::update(
996        &lock.0,
997        &resolved_id,
998        body.title.as_deref(),
999        body.content.as_deref(),
1000        body.tier.as_ref(),
1001        body.namespace.as_deref(),
1002        body.tags.as_ref(),
1003        body.priority,
1004        body.confidence,
1005        body.expires_at.as_deref(),
1006        preserved_metadata.as_ref(),
1007    ) {
1008        Ok((true, _)) => {
1009            let mem = db::get(&lock.0, &resolved_id).ok().flatten();
1010            // Issue #219: regenerate the embedding when the searchable text
1011            // (title/content) changed. Without this, the semantic index keeps
1012            // pointing at the old vector and stale semantic recall results
1013            // linger even after the row is updated.
1014            let content_changed = body.title.is_some() || body.content.is_some();
1015            let mut lock_opt = Some(lock);
1016            if content_changed && let Some(ref m) = mem {
1017                let text = format!("{} {}", m.title, m.content);
1018                if let Some(emb) = app.embedder.as_ref().as_ref() {
1019                    match emb.embed(&text) {
1020                        Ok(vec) => {
1021                            if let Some(ref l) = lock_opt
1022                                && let Err(e) = db::set_embedding(&l.0, &resolved_id, &vec)
1023                            {
1024                                tracing::warn!(
1025                                    "failed to refresh embedding for {resolved_id}: {e}"
1026                                );
1027                            }
1028                            // Drop DB lock before touching vector index.
1029                            lock_opt.take();
1030                            let mut idx_lock = app.vector_index.lock().await;
1031                            if let Some(idx) = idx_lock.as_mut() {
1032                                idx.remove(&resolved_id);
1033                                idx.insert(resolved_id.clone(), vec);
1034                            }
1035                        }
1036                        Err(e) => tracing::warn!("embedding regeneration failed: {e}"),
1037                    }
1038                }
1039            }
1040            // Drop the DB lock before fanning out — peers POST back to
1041            // our sync_push so we'd deadlock if we held it.
1042            drop(lock_opt);
1043            // v0.6.0.1: fan out the mutation to peers so remote readers
1044            // see the update, not the pre-update row. insert_if_newer on
1045            // peers sees a newer updated_at and applies.
1046            if let (Some(fed), Some(m)) = (app.federation.as_ref(), mem.as_ref())
1047                && let Ok(tracker) = crate::federation::broadcast_store_quorum(fed, m).await
1048                && let Err(err) = crate::federation::finalise_quorum(&tracker)
1049            {
1050                let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1051                return (
1052                    StatusCode::SERVICE_UNAVAILABLE,
1053                    [("Retry-After", "2")],
1054                    Json(serde_json::to_value(&payload).unwrap_or_default()),
1055                )
1056                    .into_response();
1057            }
1058            Json(json!(mem)).into_response()
1059        }
1060        Ok((false, _)) => {
1061            (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
1062        }
1063        Err(e) => {
1064            let msg = e.to_string();
1065            if msg.contains("already exists in namespace") {
1066                return (StatusCode::CONFLICT, Json(json!({"error": msg}))).into_response();
1067            }
1068            tracing::error!("handler error: {e}");
1069            (
1070                StatusCode::INTERNAL_SERVER_ERROR,
1071                Json(json!({"error": "internal server error"})),
1072            )
1073                .into_response()
1074        }
1075    }
1076}
1077
1078#[allow(clippy::too_many_lines)]
1079pub async fn delete_memory(
1080    State(app): State<AppState>,
1081    headers: HeaderMap,
1082    Path(id): Path<String>,
1083) -> impl IntoResponse {
1084    let state = app.db.clone();
1085    if let Err(e) = validate::validate_id(&id) {
1086        return (
1087            StatusCode::BAD_REQUEST,
1088            Json(json!({"error": e.to_string()})),
1089        )
1090            .into_response();
1091    }
1092    let lock = state.lock().await;
1093    // Resolve the target memory so governance has owner context.
1094    let target = match db::resolve_id(&lock.0, &id) {
1095        Ok(Some(m)) => m,
1096        Ok(None) => {
1097            return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
1098        }
1099        Err(e) => {
1100            let msg = e.to_string();
1101            if msg.contains("ambiguous ID prefix") {
1102                return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
1103            }
1104            tracing::error!("handler error: {e}");
1105            return (
1106                StatusCode::INTERNAL_SERVER_ERROR,
1107                Json(json!({"error": "internal server error"})),
1108            )
1109                .into_response();
1110        }
1111    };
1112
1113    // Task 1.9: governance enforcement (delete-side).
1114    {
1115        use crate::models::{GovernanceDecision, GovernedAction};
1116        let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
1117        let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
1118            Ok(a) => a,
1119            Err(e) => {
1120                return (
1121                    StatusCode::BAD_REQUEST,
1122                    Json(json!({"error": format!("invalid agent_id: {e}")})),
1123                )
1124                    .into_response();
1125            }
1126        };
1127        let mem_owner = target
1128            .metadata
1129            .get("agent_id")
1130            .and_then(|v| v.as_str())
1131            .map(str::to_string);
1132        let payload = json!({"id": target.id, "title": target.title});
1133        match db::enforce_governance(
1134            &lock.0,
1135            GovernedAction::Delete,
1136            &target.namespace,
1137            &agent_id,
1138            Some(&target.id),
1139            mem_owner.as_deref(),
1140            &payload,
1141        ) {
1142            Ok(GovernanceDecision::Allow) => {}
1143            Ok(GovernanceDecision::Deny(reason)) => {
1144                return (
1145                    StatusCode::FORBIDDEN,
1146                    Json(json!({"error": format!("delete denied by governance: {reason}")})),
1147                )
1148                    .into_response();
1149            }
1150            Ok(GovernanceDecision::Pending(pending_id)) => {
1151                // v0.6.2 (S34): fan out the new pending delete row so peers
1152                // see consistent governance queue state.
1153                let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
1154                let target_id = target.id.clone();
1155                drop(lock);
1156                if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
1157                    match crate::federation::broadcast_pending_quorum(fed, pa).await {
1158                        Ok(tracker) => {
1159                            if let Err(err) = crate::federation::finalise_quorum(&tracker) {
1160                                let payload =
1161                                    crate::federation::QuorumNotMetPayload::from_err(&err);
1162                                return (
1163                                    StatusCode::SERVICE_UNAVAILABLE,
1164                                    [("Retry-After", "2")],
1165                                    Json(serde_json::to_value(&payload).unwrap_or_default()),
1166                                )
1167                                    .into_response();
1168                            }
1169                        }
1170                        Err(err) => {
1171                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1172                            return (
1173                                StatusCode::SERVICE_UNAVAILABLE,
1174                                [("Retry-After", "2")],
1175                                Json(serde_json::to_value(&payload).unwrap_or_default()),
1176                            )
1177                                .into_response();
1178                        }
1179                    }
1180                }
1181                return (
1182                    StatusCode::ACCEPTED,
1183                    Json(json!({
1184                        "status": "pending",
1185                        "pending_id": pending_id,
1186                        "reason": "governance requires approval",
1187                        "action": "delete",
1188                        "memory_id": target_id,
1189                    })),
1190                )
1191                    .into_response();
1192            }
1193            Err(e) => {
1194                tracing::error!("governance error: {e}");
1195                return (
1196                    StatusCode::INTERNAL_SERVER_ERROR,
1197                    Json(json!({"error": "governance check failed"})),
1198                )
1199                    .into_response();
1200            }
1201        }
1202    }
1203
1204    let delete_outcome = db::delete(&lock.0, &target.id);
1205    // v0.6.4-017 — G9 HTTP webhook parity. Fire `memory_delete` after
1206    // the row is gone (mirrors the MCP pattern at mcp.rs:2227). Snapshot
1207    // fields come from the pre-delete `target`. Best-effort,
1208    // fire-and-forget: dispatch does a quick subscriber lookup on the
1209    // current connection and spawns a thread for the HTTP POST so the
1210    // response is never blocked. Held inside the lock so the subscriber
1211    // list query has a connection — release happens after.
1212    if matches!(delete_outcome, Ok(true)) {
1213        let details = serde_json::to_value(crate::subscriptions::DeleteEventDetails {
1214            title: target.title.clone(),
1215            tier: target.tier.to_string(),
1216        })
1217        .ok();
1218        let owner_aid = target
1219            .metadata
1220            .get("agent_id")
1221            .and_then(|v| v.as_str())
1222            .map(str::to_string);
1223        crate::subscriptions::dispatch_event_with_details(
1224            &lock.0,
1225            "memory_delete",
1226            &target.id,
1227            &target.namespace,
1228            owner_aid.as_deref(),
1229            &lock.1,
1230            details,
1231        );
1232    }
1233    // Drop DB lock before fanning out — peers POST back to our
1234    // sync_push and we'd deadlock on the shared Mutex if we held it.
1235    drop(lock);
1236    match delete_outcome {
1237        Ok(true) => {
1238            // PR-5 (issue #487): security audit trail for HTTP delete.
1239            let owner = target
1240                .metadata
1241                .get("agent_id")
1242                .and_then(|v| v.as_str())
1243                .map(str::to_string)
1244                .unwrap_or_else(|| {
1245                    headers
1246                        .get("x-agent-id")
1247                        .and_then(|v| v.to_str().ok())
1248                        .unwrap_or("anonymous")
1249                        .to_string()
1250                });
1251            crate::audit::emit(crate::audit::EventBuilder::new(
1252                crate::audit::AuditAction::Delete,
1253                crate::audit::actor(owner, "http_header", None),
1254                crate::audit::target_memory(
1255                    target.id.clone(),
1256                    target.namespace.clone(),
1257                    Some(target.title.clone()),
1258                    Some(target.tier.to_string()),
1259                    None,
1260                ),
1261            ));
1262            // v0.6.0.1: propagate tombstone via sync_push.deletions.
1263            if let Some(fed) = app.federation.as_ref()
1264                && let Ok(tracker) =
1265                    crate::federation::broadcast_delete_quorum(fed, &target.id).await
1266                && let Err(err) = crate::federation::finalise_quorum(&tracker)
1267            {
1268                let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1269                return (
1270                    StatusCode::SERVICE_UNAVAILABLE,
1271                    [("Retry-After", "2")],
1272                    Json(serde_json::to_value(&payload).unwrap_or_default()),
1273                )
1274                    .into_response();
1275            }
1276            Json(json!({"deleted": true})).into_response()
1277        }
1278        _ => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
1279    }
1280}
1281
1282#[allow(clippy::too_many_lines)]
1283pub async fn promote_memory(
1284    State(app): State<AppState>,
1285    headers: HeaderMap,
1286    Path(id): Path<String>,
1287) -> impl IntoResponse {
1288    let state = app.db.clone();
1289    if let Err(e) = validate::validate_id(&id) {
1290        return (
1291            StatusCode::BAD_REQUEST,
1292            Json(json!({"error": e.to_string()})),
1293        )
1294            .into_response();
1295    }
1296    let lock = state.lock().await;
1297    // Resolve prefix if exact ID not found — capture full memory for governance.
1298    let target = match db::resolve_id(&lock.0, &id) {
1299        Ok(Some(mem)) => mem,
1300        Ok(None) => {
1301            return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
1302        }
1303        Err(e) => {
1304            let msg = e.to_string();
1305            if msg.contains("ambiguous ID prefix") {
1306                return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
1307            }
1308            tracing::error!("handler error: {e}");
1309            return (
1310                StatusCode::INTERNAL_SERVER_ERROR,
1311                Json(json!({"error": "internal server error"})),
1312            )
1313                .into_response();
1314        }
1315    };
1316    // Task 1.9: governance enforcement (promote-side).
1317    {
1318        use crate::models::{GovernanceDecision, GovernedAction};
1319        let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
1320        let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
1321            Ok(a) => a,
1322            Err(e) => {
1323                return (
1324                    StatusCode::BAD_REQUEST,
1325                    Json(json!({"error": format!("invalid agent_id: {e}")})),
1326                )
1327                    .into_response();
1328            }
1329        };
1330        let mem_owner = target
1331            .metadata
1332            .get("agent_id")
1333            .and_then(|v| v.as_str())
1334            .map(str::to_string);
1335        let payload = json!({"id": target.id});
1336        match db::enforce_governance(
1337            &lock.0,
1338            GovernedAction::Promote,
1339            &target.namespace,
1340            &agent_id,
1341            Some(&target.id),
1342            mem_owner.as_deref(),
1343            &payload,
1344        ) {
1345            Ok(GovernanceDecision::Allow) => {}
1346            Ok(GovernanceDecision::Deny(reason)) => {
1347                return (
1348                    StatusCode::FORBIDDEN,
1349                    Json(json!({"error": format!("promote denied by governance: {reason}")})),
1350                )
1351                    .into_response();
1352            }
1353            Ok(GovernanceDecision::Pending(pending_id)) => {
1354                // v0.6.2 (S34): fan out the new pending promote row too.
1355                let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
1356                let target_id = target.id.clone();
1357                drop(lock);
1358                if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
1359                    match crate::federation::broadcast_pending_quorum(fed, pa).await {
1360                        Ok(tracker) => {
1361                            if let Err(err) = crate::federation::finalise_quorum(&tracker) {
1362                                let payload =
1363                                    crate::federation::QuorumNotMetPayload::from_err(&err);
1364                                return (
1365                                    StatusCode::SERVICE_UNAVAILABLE,
1366                                    [("Retry-After", "2")],
1367                                    Json(serde_json::to_value(&payload).unwrap_or_default()),
1368                                )
1369                                    .into_response();
1370                            }
1371                        }
1372                        Err(err) => {
1373                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1374                            return (
1375                                StatusCode::SERVICE_UNAVAILABLE,
1376                                [("Retry-After", "2")],
1377                                Json(serde_json::to_value(&payload).unwrap_or_default()),
1378                            )
1379                                .into_response();
1380                        }
1381                    }
1382                }
1383                return (
1384                    StatusCode::ACCEPTED,
1385                    Json(json!({
1386                        "status": "pending",
1387                        "pending_id": pending_id,
1388                        "reason": "governance requires approval",
1389                        "action": "promote",
1390                        "memory_id": target_id,
1391                    })),
1392                )
1393                    .into_response();
1394            }
1395            Err(e) => {
1396                tracing::error!("governance error: {e}");
1397                return (
1398                    StatusCode::INTERNAL_SERVER_ERROR,
1399                    Json(json!({"error": "governance check failed"})),
1400                )
1401                    .into_response();
1402            }
1403        }
1404    }
1405
1406    let resolved_id = target.id.clone();
1407    match db::update(
1408        &lock.0,
1409        &resolved_id,
1410        None,
1411        None,
1412        Some(&Tier::Long),
1413        None,
1414        None,
1415        None,
1416        None,
1417        None,
1418        None,
1419    ) {
1420        Ok((true, _)) => {
1421            if let Err(e) = lock.0.execute(
1422                "UPDATE memories SET expires_at = NULL WHERE id = ?1",
1423                rusqlite::params![resolved_id],
1424            ) {
1425                tracing::error!("promote clear expiry failed: {e}");
1426                return (
1427                    StatusCode::INTERNAL_SERVER_ERROR,
1428                    Json(json!({"error": "internal server error"})),
1429                )
1430                    .into_response();
1431            }
1432            // v0.6.0.1: fan out the promoted memory so peers pick up the
1433            // new tier + cleared expiry via insert_if_newer's newer-wins merge.
1434            let promoted_mem = db::get(&lock.0, &resolved_id).ok().flatten();
1435            // v0.6.4-017 — G9 HTTP webhook parity. Fire `memory_promote`
1436            // (tier mode — HTTP only does tier promotion, MCP also does
1437            // vertical). Mirrors mcp.rs:2369 pattern.
1438            let owner_aid = target
1439                .metadata
1440                .get("agent_id")
1441                .and_then(|v| v.as_str())
1442                .map(str::to_string);
1443            let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
1444                mode: "tier".to_string(),
1445                tier: Some("long".to_string()),
1446                to_namespace: None,
1447                clone_id: None,
1448            })
1449            .ok();
1450            crate::subscriptions::dispatch_event_with_details(
1451                &lock.0,
1452                "memory_promote",
1453                &resolved_id,
1454                &target.namespace,
1455                owner_aid.as_deref(),
1456                &lock.1,
1457                details,
1458            );
1459            drop(lock);
1460            if let (Some(fed), Some(m)) = (app.federation.as_ref(), promoted_mem.as_ref())
1461                && let Ok(tracker) = crate::federation::broadcast_store_quorum(fed, m).await
1462                && let Err(err) = crate::federation::finalise_quorum(&tracker)
1463            {
1464                let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1465                return (
1466                    StatusCode::SERVICE_UNAVAILABLE,
1467                    [("Retry-After", "2")],
1468                    Json(serde_json::to_value(&payload).unwrap_or_default()),
1469                )
1470                    .into_response();
1471            }
1472            Json(json!({"promoted": true, "id": resolved_id, "tier": "long"})).into_response()
1473        }
1474        Ok((false, _)) => {
1475            (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
1476        }
1477        Err(e) => {
1478            tracing::error!("handler error: {e}");
1479            (
1480                StatusCode::INTERNAL_SERVER_ERROR,
1481                Json(json!({"error": "internal server error"})),
1482            )
1483                .into_response()
1484        }
1485    }
1486}
1487
1488pub async fn list_memories(
1489    State(state): State<Db>,
1490    Query(p): Query<ListQuery>,
1491) -> impl IntoResponse {
1492    // #197: validate agent_id filter values
1493    if let Some(ref aid) = p.agent_id
1494        && let Err(e) = validate::validate_agent_id(aid)
1495    {
1496        return (
1497            StatusCode::BAD_REQUEST,
1498            Json(json!({"error": format!("invalid agent_id filter: {e}")})),
1499        )
1500            .into_response();
1501    }
1502    let lock = state.lock().await;
1503    // v0.6.2 (S40): raise ceiling from 200 → `MAX_BULK_SIZE` (1000) so bulk
1504    // fanout scenarios that POST 500+ rows to a leader can verify full
1505    // peer delivery via a single `GET /memories?limit=N` (previously the
1506    // list silently capped at 200 regardless of whether fanout worked).
1507    // Default remains 20 — only explicit `?limit=` callers see the
1508    // higher ceiling.
1509    let limit = p.limit.unwrap_or(20).min(MAX_BULK_SIZE);
1510    match db::list(
1511        &lock.0,
1512        p.namespace.as_deref(),
1513        p.tier.as_ref(),
1514        limit,
1515        p.offset.unwrap_or(0),
1516        p.min_priority,
1517        p.since.as_deref(),
1518        p.until.as_deref(),
1519        p.tags.as_deref(),
1520        p.agent_id.as_deref(),
1521    ) {
1522        Ok(mems) => Json(json!({"memories": mems, "count": mems.len()})).into_response(),
1523        Err(e) => {
1524            tracing::error!("handler error: {e}");
1525            (
1526                StatusCode::INTERNAL_SERVER_ERROR,
1527                Json(json!({"error": "internal server error"})),
1528            )
1529                .into_response()
1530        }
1531    }
1532}
1533
1534pub async fn search_memories(
1535    State(state): State<Db>,
1536    Query(p): Query<SearchQuery>,
1537) -> impl IntoResponse {
1538    if p.q.trim().is_empty() {
1539        return (
1540            StatusCode::BAD_REQUEST,
1541            Json(json!({"error": "query is required"})),
1542        )
1543            .into_response();
1544    }
1545    // #197: validate agent_id filter values
1546    if let Some(ref aid) = p.agent_id
1547        && let Err(e) = validate::validate_agent_id(aid)
1548    {
1549        return (
1550            StatusCode::BAD_REQUEST,
1551            Json(json!({"error": format!("invalid agent_id filter: {e}")})),
1552        )
1553            .into_response();
1554    }
1555    // #151 visibility: validate --as-agent namespace if supplied
1556    if let Some(ref a) = p.as_agent
1557        && let Err(e) = validate::validate_namespace(a)
1558    {
1559        return (
1560            StatusCode::BAD_REQUEST,
1561            Json(json!({"error": format!("invalid as_agent: {e}")})),
1562        )
1563            .into_response();
1564    }
1565    let lock = state.lock().await;
1566    // v0.6.2 (S40): mirror the `list_memories` ceiling raise so search
1567    // over a bulk-populated namespace isn't also capped at 200.
1568    let limit = p.limit.unwrap_or(20).min(MAX_BULK_SIZE);
1569    match db::search(
1570        &lock.0,
1571        &p.q,
1572        p.namespace.as_deref(),
1573        p.tier.as_ref(),
1574        limit,
1575        p.min_priority,
1576        p.since.as_deref(),
1577        p.until.as_deref(),
1578        p.tags.as_deref(),
1579        p.agent_id.as_deref(),
1580        p.as_agent.as_deref(),
1581    ) {
1582        Ok(r) => Json(json!({"results": r, "count": r.len(), "query": p.q})).into_response(),
1583        Err(e) => {
1584            tracing::error!("handler error: {e}");
1585            (
1586                StatusCode::INTERNAL_SERVER_ERROR,
1587                Json(json!({"error": "internal server error"})),
1588            )
1589                .into_response()
1590        }
1591    }
1592}
1593
1594pub async fn recall_memories_get(
1595    State(app): State<AppState>,
1596    Query(p): Query<RecallQuery>,
1597) -> impl IntoResponse {
1598    let ctx = p.context.unwrap_or_default();
1599    if ctx.trim().is_empty() {
1600        return (
1601            StatusCode::BAD_REQUEST,
1602            Json(json!({"error": "context is required"})),
1603        )
1604            .into_response();
1605    }
1606    // Phase P6 (R1): `budget_tokens=0` is now a valid request meaning
1607    // "return zero memories" — see `db::apply_token_budget`. The
1608    // earlier Ultrareview #348 hard-reject is replaced by always
1609    // round-tripping the requested budget in the response so a
1610    // genuinely buggy uninitialised counter is still observable.
1611    if let Some(ref a) = p.as_agent
1612        && let Err(e) = validate::validate_namespace(a)
1613    {
1614        return (
1615            StatusCode::BAD_REQUEST,
1616            Json(json!({"error": format!("invalid as_agent: {e}")})),
1617        )
1618            .into_response();
1619    }
1620    let limit = p.limit.unwrap_or(10).min(50);
1621    recall_response(
1622        &app,
1623        &ctx,
1624        p.namespace.as_deref(),
1625        limit,
1626        p.tags.as_deref(),
1627        p.since.as_deref(),
1628        p.until.as_deref(),
1629        p.as_agent.as_deref(),
1630        p.budget_tokens,
1631    )
1632    .await
1633}
1634
1635pub async fn recall_memories_post(
1636    State(app): State<AppState>,
1637    Json(body): Json<RecallBody>,
1638) -> impl IntoResponse {
1639    if body.context.trim().is_empty() {
1640        return (
1641            StatusCode::BAD_REQUEST,
1642            Json(json!({"error": "context is required"})),
1643        )
1644            .into_response();
1645    }
1646    // Phase P6 (R1): `budget_tokens=0` is now a valid request — see
1647    // the matching note on the GET handler above.
1648    if let Some(ref a) = body.as_agent
1649        && let Err(e) = validate::validate_namespace(a)
1650    {
1651        return (
1652            StatusCode::BAD_REQUEST,
1653            Json(json!({"error": format!("invalid as_agent: {e}")})),
1654        )
1655            .into_response();
1656    }
1657    let limit = body.limit.unwrap_or(10).min(50);
1658    recall_response(
1659        &app,
1660        &body.context,
1661        body.namespace.as_deref(),
1662        limit,
1663        body.tags.as_deref(),
1664        body.since.as_deref(),
1665        body.until.as_deref(),
1666        body.as_agent.as_deref(),
1667        body.budget_tokens,
1668    )
1669    .await
1670}
1671
1672/// v0.6.2 (S18): shared HTTP recall implementation. Uses `db::recall_hybrid`
1673/// (semantic + FTS adaptive blend) when the embedder is loaded — matching
1674/// how the MCP `memory_recall` handler wires recall at src/mcp.rs:1157.
1675/// Gracefully falls back to `db::recall` (keyword-only) when the embedder
1676/// is not present or embedding the query fails. Closes the gap where the
1677/// HTTP surface was keyword-only regardless of server tier — scenario-18
1678/// surfaced the black-hole on peers that fanned out memories but never
1679/// exercised the semantic recall path.
1680#[allow(clippy::too_many_arguments)]
1681async fn recall_response(
1682    app: &AppState,
1683    context: &str,
1684    namespace: Option<&str>,
1685    limit: usize,
1686    tags: Option<&str>,
1687    since: Option<&str>,
1688    until: Option<&str>,
1689    as_agent: Option<&str>,
1690    budget_tokens: Option<usize>,
1691) -> axum::response::Response {
1692    // Embed the query BEFORE grabbing the DB lock — embed() is CPU-heavy
1693    // and holding the SQLite mutex across it serialises unrelated writes.
1694    let query_emb: Option<Vec<f32>> = if let Some(emb) = app.embedder.as_ref().as_ref() {
1695        match emb.embed(context) {
1696            Ok(v) => Some(v),
1697            Err(e) => {
1698                tracing::warn!("recall: embedder query failed, falling back to keyword-only: {e}");
1699                None
1700            }
1701        }
1702    } else {
1703        None
1704    };
1705
1706    let lock = app.db.lock().await;
1707    let short_extend = lock.2.short_extend_secs;
1708    let mid_extend = lock.2.mid_extend_secs;
1709
1710    let (result, mode) = if let Some(ref qe) = query_emb {
1711        let vi_guard = app.vector_index.lock().await;
1712        let vi_ref = vi_guard.as_ref();
1713        let r = db::recall_hybrid(
1714            &lock.0,
1715            context,
1716            qe,
1717            namespace,
1718            limit,
1719            tags,
1720            since,
1721            until,
1722            vi_ref,
1723            short_extend,
1724            mid_extend,
1725            as_agent,
1726            budget_tokens,
1727            app.scoring.as_ref(),
1728        );
1729        drop(vi_guard);
1730        (r, "hybrid")
1731    } else {
1732        let r = db::recall(
1733            &lock.0,
1734            context,
1735            namespace,
1736            limit,
1737            tags,
1738            since,
1739            until,
1740            short_extend,
1741            mid_extend,
1742            as_agent,
1743            budget_tokens,
1744        );
1745        (r, "keyword")
1746    };
1747
1748    match result {
1749        Ok((r, outcome)) => {
1750            let scored: Vec<serde_json::Value> = r
1751                .iter()
1752                .map(|(m, s)| {
1753                    let mut v = serde_json::to_value(m).unwrap_or_default();
1754                    if let Some(obj) = v.as_object_mut() {
1755                        obj.insert("score".to_string(), json!((*s * 1000.0).round() / 1000.0));
1756                    }
1757                    v
1758                })
1759                .collect();
1760            let mut resp = json!({
1761                "memories": scored,
1762                "count": scored.len(),
1763                "tokens_used": outcome.tokens_used,
1764                "mode": mode,
1765            });
1766            if let Some(b) = budget_tokens {
1767                resp["budget_tokens"] = json!(b);
1768                // Phase P6 (R1) meta block — same shape as the MCP path.
1769                resp["meta"] = json!({
1770                    "budget_tokens_used": outcome.tokens_used,
1771                    "budget_tokens_remaining": outcome.tokens_remaining.unwrap_or(0),
1772                    "memories_dropped": outcome.memories_dropped,
1773                    "budget_overflow": outcome.budget_overflow,
1774                });
1775            }
1776            Json(resp).into_response()
1777        }
1778        Err(e) => {
1779            tracing::error!("handler error: {e}");
1780            (
1781                StatusCode::INTERNAL_SERVER_ERROR,
1782                Json(json!({"error": "internal server error"})),
1783            )
1784                .into_response()
1785        }
1786    }
1787}
1788
1789pub async fn forget_memories(
1790    State(state): State<Db>,
1791    Json(body): Json<ForgetQuery>,
1792) -> impl IntoResponse {
1793    let lock = state.lock().await;
1794    match db::forget(
1795        &lock.0,
1796        body.namespace.as_deref(),
1797        body.pattern.as_deref(),
1798        body.tier.as_ref(),
1799        lock.3, // archive_on_gc
1800    ) {
1801        Ok(n) => Json(json!({"deleted": n})).into_response(),
1802        Err(e) => (
1803            StatusCode::BAD_REQUEST,
1804            Json(json!({"error": e.to_string()})),
1805        )
1806            .into_response(),
1807    }
1808}
1809
1810#[derive(Deserialize)]
1811pub struct ContradictionsQuery {
1812    /// Topic to group candidate memories by. Resolved via (in order):
1813    /// `metadata.topic` exact match, then `title` exact match, then FTS
1814    /// content substring. At least one of `topic` or `namespace` is required.
1815    pub topic: Option<String>,
1816    /// Namespace to scope the search. Optional — default is cross-namespace.
1817    pub namespace: Option<String>,
1818    /// Pagination cap. Defaults to 50, hard max 200.
1819    pub limit: Option<usize>,
1820}
1821
1822/// HTTP handler for v0.6.0.1 issue #321 — surfaces contradiction candidates
1823/// over the same REST surface scenarios use, so a2a-gate scenario-6 and any
1824/// future federation-level contradiction probe don't have to go through the
1825/// MCP stdio path.
1826///
1827/// Returns `{memories, links}` where:
1828/// - `memories` are the candidates grouped by topic/title (respecting the
1829///   UPSERT (title, namespace) invariant: if writers collided, only the LWW
1830///   survivor is returned — callers should use distinct titles per writer).
1831/// - `links` includes any existing `contradicts` rows from the `memory_links`
1832///   table PLUS a heuristic synthesis: when ≥2 candidates share a topic/title
1833///   but have materially different content, emit a synthetic `contradicts`
1834///   relation between each pair. The synthesized links carry
1835///   `relation:"contradicts"` and a `synthesized:true` flag so callers can
1836///   distinguish them from LLM-detected or operator-authored links.
1837///
1838/// Heuristic-only intentionally — LLM-backed detection (the existing MCP
1839/// `memory_detect_contradiction` tool) stays MCP-scoped so the HTTP surface
1840/// has no runtime LLM dependency. A follow-up issue can add opt-in LLM
1841/// resolution when `config.tier == Smart | Autonomous`.
1842#[allow(clippy::too_many_lines)]
1843pub async fn detect_contradictions(
1844    State(state): State<Db>,
1845    Query(q): Query<ContradictionsQuery>,
1846) -> impl IntoResponse {
1847    if q.topic.is_none() && q.namespace.is_none() {
1848        return (
1849            StatusCode::BAD_REQUEST,
1850            Json(json!({"error": "at least one of `topic` or `namespace` is required"})),
1851        )
1852            .into_response();
1853    }
1854    if let Some(ref ns) = q.namespace
1855        && let Err(e) = validate::validate_namespace(ns)
1856    {
1857        return (
1858            StatusCode::BAD_REQUEST,
1859            Json(json!({"error": e.to_string()})),
1860        )
1861            .into_response();
1862    }
1863    // v0.6.2 (S40): raise to `MAX_BULK_SIZE` so a detect-contradictions
1864    // sweep over a bulk-populated namespace isn't silently capped at 200.
1865    let limit = q.limit.unwrap_or(50).min(MAX_BULK_SIZE);
1866    let lock = state.lock().await;
1867    let all = match db::list(
1868        &lock.0,
1869        q.namespace.as_deref(),
1870        None,
1871        limit,
1872        0,
1873        None,
1874        None,
1875        None,
1876        None,
1877        None,
1878    ) {
1879        Ok(v) => v,
1880        Err(e) => {
1881            tracing::error!("detect_contradictions list error: {e}");
1882            return (
1883                StatusCode::INTERNAL_SERVER_ERROR,
1884                Json(json!({"error": "internal server error"})),
1885            )
1886                .into_response();
1887        }
1888    };
1889
1890    // Topic match: metadata.topic == topic OR title == topic. Kept as a
1891    // retained filter rather than pushing to SQL because metadata is JSON
1892    // and the match predicate may evolve.
1893    let candidates: Vec<Memory> = match q.topic.as_deref() {
1894        Some(t) => all
1895            .into_iter()
1896            .filter(|m| {
1897                m.metadata
1898                    .get("topic")
1899                    .and_then(|v| v.as_str())
1900                    .is_some_and(|s| s == t)
1901                    || m.title == t
1902            })
1903            .collect(),
1904        None => all,
1905    };
1906
1907    // Existing contradicts links involving any candidate.
1908    let candidate_ids: std::collections::HashSet<String> =
1909        candidates.iter().map(|m| m.id.clone()).collect();
1910    let mut existing_links: Vec<serde_json::Value> = Vec::new();
1911    for id in &candidate_ids {
1912        if let Ok(links) = db::get_links(&lock.0, id) {
1913            for link in links {
1914                if link.relation.contains("contradict")
1915                    && candidate_ids.contains(&link.source_id)
1916                    && candidate_ids.contains(&link.target_id)
1917                {
1918                    existing_links.push(json!({
1919                        "source_id": link.source_id,
1920                        "target_id": link.target_id,
1921                        "relation": link.relation,
1922                        "synthesized": false,
1923                    }));
1924                }
1925            }
1926        }
1927    }
1928    // Dedup — each (source,target,relation) appears at most once.
1929    existing_links.sort_by_key(|v| {
1930        (
1931            v.get("source_id")
1932                .and_then(|s| s.as_str())
1933                .unwrap_or("")
1934                .to_string(),
1935            v.get("target_id")
1936                .and_then(|s| s.as_str())
1937                .unwrap_or("")
1938                .to_string(),
1939            v.get("relation")
1940                .and_then(|s| s.as_str())
1941                .unwrap_or("")
1942                .to_string(),
1943        )
1944    });
1945    existing_links.dedup_by_key(|v| {
1946        (
1947            v.get("source_id")
1948                .and_then(|s| s.as_str())
1949                .unwrap_or("")
1950                .to_string(),
1951            v.get("target_id")
1952                .and_then(|s| s.as_str())
1953                .unwrap_or("")
1954                .to_string(),
1955            v.get("relation")
1956                .and_then(|s| s.as_str())
1957                .unwrap_or("")
1958                .to_string(),
1959        )
1960    });
1961
1962    // Heuristic: when ≥2 candidates share a topic/title but content
1963    // differs, synthesize pairwise contradicts links. Marked
1964    // synthesized:true so callers can treat operator-authored links as
1965    // higher-confidence than this fallback.
1966    let mut synth_links: Vec<serde_json::Value> = Vec::new();
1967    for (i, a) in candidates.iter().enumerate() {
1968        for b in candidates.iter().skip(i + 1) {
1969            let same_topic = match q.topic.as_deref() {
1970                Some(_) => true,
1971                None => a.title == b.title,
1972            };
1973            if same_topic && a.content != b.content && a.id != b.id {
1974                synth_links.push(json!({
1975                    "source_id": a.id,
1976                    "target_id": b.id,
1977                    "relation": "contradicts",
1978                    "synthesized": true,
1979                }));
1980            }
1981        }
1982    }
1983
1984    let mut links = existing_links;
1985    links.extend(synth_links);
1986
1987    Json(json!({
1988        "memories": candidates,
1989        "links": links,
1990    }))
1991    .into_response()
1992}
1993
1994pub async fn list_namespaces(State(state): State<Db>) -> impl IntoResponse {
1995    let lock = state.lock().await;
1996    match db::list_namespaces(&lock.0) {
1997        Ok(ns) => Json(json!({"namespaces": ns})).into_response(),
1998        Err(e) => {
1999            tracing::error!("handler error: {e}");
2000            (
2001                StatusCode::INTERNAL_SERVER_ERROR,
2002                Json(json!({"error": "internal server error"})),
2003            )
2004                .into_response()
2005        }
2006    }
2007}
2008
2009/// Query parameters for `GET /api/v1/taxonomy` (Pillar 1 / Stream A).
2010#[derive(Debug, Deserialize)]
2011pub struct TaxonomyQuery {
2012    /// Restrict to memories at this namespace OR any descendant. Trailing
2013    /// `/` is tolerated. Omit to walk the whole tree.
2014    pub prefix: Option<String>,
2015    /// Max levels to descend below the prefix (defaults to 8 — the
2016    /// hierarchy hard cap).
2017    pub depth: Option<usize>,
2018    /// Cap on the number of `(namespace, count)` rows we walk into the
2019    /// tree. Densest namespaces win when truncated. Defaults to 1000.
2020    pub limit: Option<usize>,
2021}
2022
2023/// `GET /api/v1/taxonomy` — REST mirror of the MCP `memory_get_taxonomy`
2024/// tool. Returns the prefix's hierarchical tree with per-node and
2025/// subtree counts, plus an honest `total_count` and a `truncated`
2026/// flag when `limit` dropped rows from the walk.
2027pub async fn get_taxonomy(
2028    State(state): State<Db>,
2029    Query(p): Query<TaxonomyQuery>,
2030) -> impl IntoResponse {
2031    let prefix_owned: Option<String> = p
2032        .prefix
2033        .as_deref()
2034        .map(str::trim)
2035        .filter(|s| !s.is_empty())
2036        .map(|s| s.trim_end_matches('/').to_string());
2037    if let Some(pref) = prefix_owned.as_deref()
2038        && let Err(e) = validate::validate_namespace(pref)
2039    {
2040        return (
2041            StatusCode::BAD_REQUEST,
2042            Json(json!({"error": format!("invalid namespace_prefix: {e}")})),
2043        )
2044            .into_response();
2045    }
2046    let depth = p
2047        .depth
2048        .unwrap_or(crate::models::MAX_NAMESPACE_DEPTH)
2049        .min(crate::models::MAX_NAMESPACE_DEPTH);
2050    let limit = p.limit.unwrap_or(1000).clamp(1, 10_000);
2051    let lock = state.lock().await;
2052    match db::get_taxonomy(&lock.0, prefix_owned.as_deref(), depth, limit) {
2053        Ok(tax) => Json(json!({
2054            "tree": tax.tree,
2055            "total_count": tax.total_count,
2056            "truncated": tax.truncated,
2057        }))
2058        .into_response(),
2059        Err(e) => {
2060            tracing::error!("handler error: {e}");
2061            (
2062                StatusCode::INTERNAL_SERVER_ERROR,
2063                Json(json!({"error": "internal server error"})),
2064            )
2065                .into_response()
2066        }
2067    }
2068}
2069
2070/// Request body for `POST /api/v1/check_duplicate` (Pillar 2 / Stream D).
2071#[derive(Debug, Deserialize)]
2072pub struct CheckDuplicateBody {
2073    pub title: String,
2074    pub content: String,
2075    /// Restrict the duplicate scan to this namespace. Omit to scan all
2076    /// namespaces.
2077    pub namespace: Option<String>,
2078    /// Cosine similarity threshold for declaring a duplicate. Clamped
2079    /// to >= 0.5 inside `db::check_duplicate`. Defaults to the tuned
2080    /// `DUPLICATE_THRESHOLD_DEFAULT` when omitted.
2081    pub threshold: Option<f32>,
2082}
2083
2084/// `POST /api/v1/check_duplicate` — REST mirror of the MCP
2085/// `memory_check_duplicate` tool. Embeds `title + content`, scans
2086/// embedded live memories, and returns the highest-cosine match plus
2087/// `is_duplicate`/`suggested_merge` derived from the (clamped)
2088/// threshold.
2089pub async fn check_duplicate(
2090    State(app): State<AppState>,
2091    Json(body): Json<CheckDuplicateBody>,
2092) -> impl IntoResponse {
2093    if let Err(e) = validate::validate_title(&body.title) {
2094        return (
2095            StatusCode::BAD_REQUEST,
2096            Json(json!({"error": format!("invalid title: {e}")})),
2097        )
2098            .into_response();
2099    }
2100    if let Err(e) = validate::validate_content(&body.content) {
2101        return (
2102            StatusCode::BAD_REQUEST,
2103            Json(json!({"error": format!("invalid content: {e}")})),
2104        )
2105            .into_response();
2106    }
2107    let namespace = body
2108        .namespace
2109        .as_deref()
2110        .map(str::trim)
2111        .filter(|s| !s.is_empty());
2112    if let Some(ns) = namespace
2113        && let Err(e) = validate::validate_namespace(ns)
2114    {
2115        return (
2116            StatusCode::BAD_REQUEST,
2117            Json(json!({"error": format!("invalid namespace: {e}")})),
2118        )
2119            .into_response();
2120    }
2121    let threshold = body.threshold.unwrap_or(db::DUPLICATE_THRESHOLD_DEFAULT);
2122
2123    // Embed before taking the DB lock — same rationale as create_memory
2124    // (issue #219). The embedder call is 10-200ms; we don't want it
2125    // serialised behind the connection mutex.
2126    let embedding_text = format!("{} {}", body.title, body.content);
2127    let query_embedding = match app.embedder.as_ref().as_ref() {
2128        Some(emb) => match emb.embed(&embedding_text) {
2129            Ok(v) => v,
2130            Err(e) => {
2131                tracing::warn!("embedding generation failed: {e}");
2132                return (
2133                    StatusCode::SERVICE_UNAVAILABLE,
2134                    Json(json!({"error": "embedder failed to encode input"})),
2135                )
2136                    .into_response();
2137            }
2138        },
2139        None => {
2140            return (
2141                StatusCode::SERVICE_UNAVAILABLE,
2142                Json(json!({
2143                    "error": "memory_check_duplicate requires the embedder; daemon must be started with semantic tier or above"
2144                })),
2145            )
2146                .into_response();
2147        }
2148    };
2149
2150    let lock = app.db.lock().await;
2151    let check = match db::check_duplicate(&lock.0, &query_embedding, namespace, threshold) {
2152        Ok(c) => c,
2153        Err(e) => {
2154            tracing::error!("handler error: {e}");
2155            return (
2156                StatusCode::INTERNAL_SERVER_ERROR,
2157                Json(json!({"error": "internal server error"})),
2158            )
2159                .into_response();
2160        }
2161    };
2162
2163    let nearest_json = check.nearest.as_ref().map(|m| {
2164        json!({
2165            "id": m.id,
2166            "title": m.title,
2167            "namespace": m.namespace,
2168            "similarity": (m.similarity * 1000.0).round() / 1000.0,
2169        })
2170    });
2171    let suggested_merge = if check.is_duplicate {
2172        check.nearest.as_ref().map(|m| m.id.clone())
2173    } else {
2174        None
2175    };
2176
2177    Json(json!({
2178        "is_duplicate": check.is_duplicate,
2179        "threshold": check.threshold,
2180        "nearest": nearest_json,
2181        "suggested_merge": suggested_merge,
2182        "candidates_scanned": check.candidates_scanned,
2183    }))
2184    .into_response()
2185}
2186
2187/// Request body for `POST /api/v1/entities` (Pillar 2 / Stream B).
2188#[derive(Debug, Deserialize)]
2189pub struct EntityRegisterBody {
2190    pub canonical_name: String,
2191    pub namespace: String,
2192    /// Aliases that should resolve to this entity. Blanks are skipped;
2193    /// duplicates collapse via `entity_aliases`'s primary key.
2194    #[serde(default)]
2195    pub aliases: Vec<String>,
2196    /// Arbitrary metadata to merge onto the entity memory. `kind` is
2197    /// always overwritten with `"entity"`.
2198    #[serde(default)]
2199    pub metadata: serde_json::Value,
2200    /// Override the resolved NHI for this request's
2201    /// `metadata.agent_id`. Falls back to the `X-Agent-Id` header
2202    /// when omitted.
2203    pub agent_id: Option<String>,
2204}
2205
2206/// Query parameters for `GET /api/v1/entities/by_alias` (Pillar 2 /
2207/// Stream B).
2208#[derive(Debug, Deserialize)]
2209pub struct EntityByAliasQuery {
2210    pub alias: String,
2211    pub namespace: Option<String>,
2212}
2213
2214/// `POST /api/v1/entities` — REST mirror of the MCP
2215/// `memory_entity_register` tool. Idempotent on
2216/// `(canonical_name, namespace)`; merges aliases on re-registration.
2217pub async fn entity_register(
2218    State(state): State<Db>,
2219    headers: HeaderMap,
2220    Json(body): Json<EntityRegisterBody>,
2221) -> impl IntoResponse {
2222    if let Err(e) = validate::validate_title(&body.canonical_name) {
2223        return (
2224            StatusCode::BAD_REQUEST,
2225            Json(json!({"error": format!("invalid canonical_name: {e}")})),
2226        )
2227            .into_response();
2228    }
2229    if let Err(e) = validate::validate_namespace(&body.namespace) {
2230        return (
2231            StatusCode::BAD_REQUEST,
2232            Json(json!({"error": format!("invalid namespace: {e}")})),
2233        )
2234            .into_response();
2235    }
2236
2237    let agent_id = body
2238        .agent_id
2239        .as_deref()
2240        .or_else(|| headers.get("x-agent-id").and_then(|v| v.to_str().ok()))
2241        .map(str::trim)
2242        .filter(|s| !s.is_empty())
2243        .map(str::to_string);
2244    if let Some(aid) = agent_id.as_deref()
2245        && let Err(e) = validate::validate_agent_id(aid)
2246    {
2247        return (
2248            StatusCode::BAD_REQUEST,
2249            Json(json!({"error": format!("invalid agent_id: {e}")})),
2250        )
2251            .into_response();
2252    }
2253
2254    let extra_metadata = if body.metadata.is_object() {
2255        body.metadata.clone()
2256    } else {
2257        json!({})
2258    };
2259
2260    let lock = state.lock().await;
2261    match db::entity_register(
2262        &lock.0,
2263        &body.canonical_name,
2264        &body.namespace,
2265        &body.aliases,
2266        &extra_metadata,
2267        agent_id.as_deref(),
2268    ) {
2269        Ok(reg) => {
2270            let status = if reg.created {
2271                StatusCode::CREATED
2272            } else {
2273                StatusCode::OK
2274            };
2275            (
2276                status,
2277                Json(json!({
2278                    "entity_id": reg.entity_id,
2279                    "canonical_name": reg.canonical_name,
2280                    "namespace": reg.namespace,
2281                    "aliases": reg.aliases,
2282                    "created": reg.created,
2283                })),
2284            )
2285                .into_response()
2286        }
2287        Err(e) => {
2288            // Title-collision errors carry a stable, recognisable
2289            // substring; surface them as 409 Conflict so callers can
2290            // distinguish a genuine name clash from internal failure.
2291            let msg = e.to_string();
2292            if msg.contains("non-entity memory") {
2293                return (StatusCode::CONFLICT, Json(json!({"error": msg}))).into_response();
2294            }
2295            tracing::error!("handler error: {e}");
2296            (
2297                StatusCode::INTERNAL_SERVER_ERROR,
2298                Json(json!({"error": "internal server error"})),
2299            )
2300                .into_response()
2301        }
2302    }
2303}
2304
2305/// `GET /api/v1/entities/by_alias?alias=<>&namespace=<>` — REST mirror
2306/// of the MCP `memory_entity_get_by_alias` tool. Returns
2307/// `{ found: false, ... }` with HTTP 200 when no entity claims the
2308/// alias under the filter, so callers don't have to disambiguate
2309/// "no match" from a server error.
2310pub async fn entity_get_by_alias(
2311    State(state): State<Db>,
2312    Query(p): Query<EntityByAliasQuery>,
2313) -> impl IntoResponse {
2314    let alias = p.alias.trim();
2315    if alias.is_empty() {
2316        return (
2317            StatusCode::BAD_REQUEST,
2318            Json(json!({"error": "alias is required"})),
2319        )
2320            .into_response();
2321    }
2322    let namespace = p
2323        .namespace
2324        .as_deref()
2325        .map(str::trim)
2326        .filter(|s| !s.is_empty());
2327    if let Some(ns) = namespace
2328        && let Err(e) = validate::validate_namespace(ns)
2329    {
2330        return (
2331            StatusCode::BAD_REQUEST,
2332            Json(json!({"error": format!("invalid namespace: {e}")})),
2333        )
2334            .into_response();
2335    }
2336
2337    let lock = state.lock().await;
2338    match db::entity_get_by_alias(&lock.0, alias, namespace) {
2339        Ok(Some(rec)) => Json(json!({
2340            "found": true,
2341            "entity_id": rec.entity_id,
2342            "canonical_name": rec.canonical_name,
2343            "namespace": rec.namespace,
2344            "aliases": rec.aliases,
2345        }))
2346        .into_response(),
2347        Ok(None) => Json(json!({
2348            "found": false,
2349            "entity_id": null,
2350            "canonical_name": null,
2351            "namespace": null,
2352            "aliases": [],
2353        }))
2354        .into_response(),
2355        Err(e) => {
2356            tracing::error!("handler error: {e}");
2357            (
2358                StatusCode::INTERNAL_SERVER_ERROR,
2359                Json(json!({"error": "internal server error"})),
2360            )
2361                .into_response()
2362        }
2363    }
2364}
2365
2366/// Query parameters for `GET /api/v1/kg/timeline` (Pillar 2 / Stream C).
2367#[derive(Debug, Deserialize)]
2368pub struct KgTimelineQuery {
2369    pub source_id: String,
2370    pub since: Option<String>,
2371    pub until: Option<String>,
2372    pub limit: Option<usize>,
2373}
2374
2375/// `GET /api/v1/kg/timeline?source_id=<>&since=<>&until=<>&limit=<>` —
2376/// REST mirror of the MCP `memory_kg_timeline` tool. Returns outbound
2377/// link assertions from `source_id` ordered by `valid_from ASC`.
2378pub async fn kg_timeline(
2379    State(state): State<Db>,
2380    Query(p): Query<KgTimelineQuery>,
2381) -> impl IntoResponse {
2382    if let Err(e) = validate::validate_id(&p.source_id) {
2383        return (
2384            StatusCode::BAD_REQUEST,
2385            Json(json!({"error": format!("invalid source_id: {e}")})),
2386        )
2387            .into_response();
2388    }
2389    let since = p.since.as_deref().map(str::trim).filter(|s| !s.is_empty());
2390    let until = p.until.as_deref().map(str::trim).filter(|s| !s.is_empty());
2391    if let Some(s) = since
2392        && let Err(e) = validate::validate_expires_at_format(s)
2393    {
2394        return (
2395            StatusCode::BAD_REQUEST,
2396            Json(json!({"error": format!("invalid since: {e}")})),
2397        )
2398            .into_response();
2399    }
2400    if let Some(u) = until
2401        && let Err(e) = validate::validate_expires_at_format(u)
2402    {
2403        return (
2404            StatusCode::BAD_REQUEST,
2405            Json(json!({"error": format!("invalid until: {e}")})),
2406        )
2407            .into_response();
2408    }
2409
2410    let lock = state.lock().await;
2411    match db::kg_timeline(&lock.0, &p.source_id, since, until, p.limit) {
2412        Ok(events) => {
2413            let events_json: Vec<serde_json::Value> = events
2414                .iter()
2415                .map(|e| {
2416                    json!({
2417                        "target_id": e.target_id,
2418                        "relation": e.relation,
2419                        "valid_from": e.valid_from,
2420                        "valid_until": e.valid_until,
2421                        "observed_by": e.observed_by,
2422                        "title": e.title,
2423                        "target_namespace": e.target_namespace,
2424                    })
2425                })
2426                .collect();
2427            Json(json!({
2428                "source_id": p.source_id,
2429                "events": events_json,
2430                "count": events.len(),
2431            }))
2432            .into_response()
2433        }
2434        Err(e) => {
2435            tracing::error!("handler error: {e}");
2436            (
2437                StatusCode::INTERNAL_SERVER_ERROR,
2438                Json(json!({"error": "internal server error"})),
2439            )
2440                .into_response()
2441        }
2442    }
2443}
2444
2445/// JSON body for `POST /api/v1/kg/invalidate` (Pillar 2 / Stream C —
2446/// `memory_kg_invalidate`). The link is identified by its composite
2447/// key; `valid_until` defaults to wall-clock now when omitted.
2448#[derive(Debug, Deserialize)]
2449pub struct KgInvalidateBody {
2450    pub source_id: String,
2451    pub target_id: String,
2452    pub relation: String,
2453    pub valid_until: Option<String>,
2454}
2455
2456/// `POST /api/v1/kg/invalidate` — REST mirror of `memory_kg_invalidate`.
2457/// 200 with `{found: true, …, previous_valid_until}` when the link
2458/// existed; 404 with `{found: false}` when no link matches the triple.
2459pub async fn kg_invalidate(
2460    State(state): State<Db>,
2461    Json(body): Json<KgInvalidateBody>,
2462) -> impl IntoResponse {
2463    if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2464        return (
2465            StatusCode::BAD_REQUEST,
2466            Json(json!({"error": e.to_string()})),
2467        )
2468            .into_response();
2469    }
2470    let valid_until = body
2471        .valid_until
2472        .as_deref()
2473        .map(str::trim)
2474        .filter(|s| !s.is_empty());
2475    if let Some(ts) = valid_until
2476        && let Err(e) = validate::validate_expires_at_format(ts)
2477    {
2478        return (
2479            StatusCode::BAD_REQUEST,
2480            Json(json!({"error": format!("invalid valid_until: {e}")})),
2481        )
2482            .into_response();
2483    }
2484
2485    let lock = state.lock().await;
2486    match db::invalidate_link(
2487        &lock.0,
2488        &body.source_id,
2489        &body.target_id,
2490        &body.relation,
2491        valid_until,
2492    ) {
2493        Ok(Some(res)) => (
2494            StatusCode::OK,
2495            Json(json!({
2496                "found": true,
2497                "source_id": body.source_id,
2498                "target_id": body.target_id,
2499                "relation": body.relation,
2500                "valid_until": res.valid_until,
2501                "previous_valid_until": res.previous_valid_until,
2502            })),
2503        )
2504            .into_response(),
2505        Ok(None) => (
2506            StatusCode::NOT_FOUND,
2507            Json(json!({
2508                "found": false,
2509                "source_id": body.source_id,
2510                "target_id": body.target_id,
2511                "relation": body.relation,
2512            })),
2513        )
2514            .into_response(),
2515        Err(e) => {
2516            tracing::error!("handler error: {e}");
2517            (
2518                StatusCode::INTERNAL_SERVER_ERROR,
2519                Json(json!({"error": "internal server error"})),
2520            )
2521                .into_response()
2522        }
2523    }
2524}
2525
2526/// JSON body for `POST /api/v1/kg/query` (Pillar 2 / Stream C —
2527/// `memory_kg_query`). POST is used because `allowed_agents` is a list;
2528/// keeping it in a body avoids over-long query strings and keeps the
2529/// surface symmetric with `POST /api/v1/kg/invalidate`. `max_depth`
2530/// defaults to 1 and is bounded by `KG_QUERY_MAX_SUPPORTED_DEPTH`.
2531#[derive(Debug, Deserialize)]
2532pub struct KgQueryBody {
2533    pub source_id: String,
2534    pub max_depth: Option<usize>,
2535    pub valid_at: Option<String>,
2536    pub allowed_agents: Option<Vec<String>>,
2537    pub limit: Option<usize>,
2538}
2539
2540/// `POST /api/v1/kg/query` — REST mirror of the MCP `memory_kg_query`
2541/// tool. Returns outbound multi-hop traversal from `source_id` (1..=5
2542/// hops) filtered by the temporal/agent windows. 400 for invalid
2543/// IDs/timestamps; 422 when `max_depth` exceeds the supported ceiling
2544/// (clearer than 500 for what is a documented limitation, not an
2545/// internal error).
2546pub async fn kg_query(State(state): State<Db>, Json(body): Json<KgQueryBody>) -> impl IntoResponse {
2547    if let Err(e) = validate::validate_id(&body.source_id) {
2548        return (
2549            StatusCode::BAD_REQUEST,
2550            Json(json!({"error": format!("invalid source_id: {e}")})),
2551        )
2552            .into_response();
2553    }
2554    let max_depth = body.max_depth.unwrap_or(1);
2555    let valid_at = body
2556        .valid_at
2557        .as_deref()
2558        .map(str::trim)
2559        .filter(|s| !s.is_empty());
2560    if let Some(t) = valid_at
2561        && let Err(e) = validate::validate_expires_at_format(t)
2562    {
2563        return (
2564            StatusCode::BAD_REQUEST,
2565            Json(json!({"error": format!("invalid valid_at: {e}")})),
2566        )
2567            .into_response();
2568    }
2569    let allowed_agents: Option<Vec<String>> = body.allowed_agents.as_ref().map(|v| {
2570        v.iter()
2571            .map(|s| s.trim().to_string())
2572            .filter(|s| !s.is_empty())
2573            .collect()
2574    });
2575    if let Some(agents) = allowed_agents.as_ref() {
2576        for a in agents {
2577            if let Err(e) = validate::validate_agent_id(a) {
2578                return (
2579                    StatusCode::BAD_REQUEST,
2580                    Json(json!({"error": format!("invalid allowed_agents entry: {e}")})),
2581                )
2582                    .into_response();
2583            }
2584        }
2585    }
2586
2587    let lock = state.lock().await;
2588    match db::kg_query(
2589        &lock.0,
2590        &body.source_id,
2591        max_depth,
2592        valid_at,
2593        allowed_agents.as_deref(),
2594        body.limit,
2595    ) {
2596        Ok(nodes) => {
2597            let memories_json: Vec<serde_json::Value> = nodes
2598                .iter()
2599                .map(|n| {
2600                    json!({
2601                        "target_id": n.target_id,
2602                        "relation": n.relation,
2603                        "valid_from": n.valid_from,
2604                        "valid_until": n.valid_until,
2605                        "observed_by": n.observed_by,
2606                        "title": n.title,
2607                        "target_namespace": n.target_namespace,
2608                        "depth": n.depth,
2609                        "path": n.path,
2610                    })
2611                })
2612                .collect();
2613            let paths_json: Vec<&str> = nodes.iter().map(|n| n.path.as_str()).collect();
2614            Json(json!({
2615                "source_id": body.source_id,
2616                "max_depth": max_depth,
2617                "memories": memories_json,
2618                "paths": paths_json,
2619                "count": nodes.len(),
2620            }))
2621            .into_response()
2622        }
2623        Err(e) => {
2624            // The `kg_query` DB layer raises explicit errors for
2625            // depth=0 and for max_depth past the supported ceiling;
2626            // those are caller-fixable, not server faults.
2627            let msg = e.to_string();
2628            if msg.contains("max_depth") {
2629                return (
2630                    StatusCode::UNPROCESSABLE_ENTITY,
2631                    Json(json!({"error": msg})),
2632                )
2633                    .into_response();
2634            }
2635            tracing::error!("handler error: {e}");
2636            (
2637                StatusCode::INTERNAL_SERVER_ERROR,
2638                Json(json!({"error": "internal server error"})),
2639            )
2640                .into_response()
2641        }
2642    }
2643}
2644
2645pub async fn create_link(
2646    State(app): State<AppState>,
2647    Json(body): Json<LinkBody>,
2648) -> impl IntoResponse {
2649    if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2650        return (
2651            StatusCode::BAD_REQUEST,
2652            Json(json!({"error": e.to_string()})),
2653        )
2654            .into_response();
2655    }
2656    let lock = app.db.lock().await;
2657    let create_result = db::create_link(&lock.0, &body.source_id, &body.target_id, &body.relation);
2658    // v0.6.4-017 — G9 HTTP webhook parity. Fire `memory_link_created`
2659    // after db::create_link commits (mirrors mcp.rs:2569). The link
2660    // itself does not carry a namespace; we look up the source memory
2661    // for the namespace + owner agent_id so the event payload matches
2662    // the MCP contract.
2663    if create_result.is_ok() {
2664        let (link_namespace, link_owner) = db::get(&lock.0, &body.source_id)
2665            .ok()
2666            .flatten()
2667            .map_or_else(
2668                || ("global".to_string(), None),
2669                |m| {
2670                    let owner = m
2671                        .metadata
2672                        .get("agent_id")
2673                        .and_then(|v| v.as_str())
2674                        .map(str::to_string);
2675                    (m.namespace, owner)
2676                },
2677            );
2678        let details = serde_json::to_value(crate::subscriptions::LinkCreatedEventDetails {
2679            target_id: body.target_id.clone(),
2680            relation: body.relation.clone(),
2681        })
2682        .ok();
2683        crate::subscriptions::dispatch_event_with_details(
2684            &lock.0,
2685            "memory_link_created",
2686            &body.source_id,
2687            &link_namespace,
2688            link_owner.as_deref(),
2689            &lock.1,
2690            details,
2691        );
2692    }
2693    // Drop DB lock before fanning out — peers POST back to our sync_push
2694    // and we'd deadlock on the shared Mutex if we held it.
2695    drop(lock);
2696    match create_result {
2697        Ok(()) => {
2698            // v0.6.2 (#325): propagate link to peers.
2699            if let Some(fed) = app.federation.as_ref() {
2700                let link = crate::models::MemoryLink {
2701                    source_id: body.source_id.clone(),
2702                    target_id: body.target_id.clone(),
2703                    relation: body.relation.clone(),
2704                    created_at: chrono::Utc::now().to_rfc3339(),
2705                };
2706                match crate::federation::broadcast_link_quorum(fed, &link).await {
2707                    Ok(tracker) => {
2708                        if let Err(err) = crate::federation::finalise_quorum(&tracker) {
2709                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
2710                            return (
2711                                StatusCode::SERVICE_UNAVAILABLE,
2712                                [("Retry-After", "2")],
2713                                Json(serde_json::to_value(&payload).unwrap_or_default()),
2714                            )
2715                                .into_response();
2716                        }
2717                    }
2718                    Err(e) => {
2719                        tracing::warn!("link fanout error (local committed): {e:?}");
2720                    }
2721                }
2722            }
2723            (StatusCode::CREATED, Json(json!({"linked": true}))).into_response()
2724        }
2725        Err(e) => {
2726            tracing::error!("handler error: {e}");
2727            (
2728                StatusCode::INTERNAL_SERVER_ERROR,
2729                Json(json!({"error": "internal server error"})),
2730            )
2731                .into_response()
2732        }
2733    }
2734}
2735
2736/// v0.6.2 (#325) — DELETE /api/v1/links. Removes the directional link
2737/// `source_id → target_id` locally. Deletion is NOT fanned out in v0.6.2:
2738/// the receiving-side API is `db::delete_link`, and `sync_push` does not
2739/// yet carry a link-tombstone list. Full link tombstones ship with v0.7
2740/// CRDT-lite. For current scenario coverage (scenario-11 tests create),
2741/// create-link fanout is sufficient.
2742pub async fn delete_link(
2743    State(app): State<AppState>,
2744    Json(body): Json<LinkBody>,
2745) -> impl IntoResponse {
2746    if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2747        return (
2748            StatusCode::BAD_REQUEST,
2749            Json(json!({"error": e.to_string()})),
2750        )
2751            .into_response();
2752    }
2753    let lock = app.db.lock().await;
2754    let delete_result = db::delete_link(&lock.0, &body.source_id, &body.target_id);
2755    drop(lock);
2756    match delete_result {
2757        Ok(removed) => Json(json!({"deleted": removed})).into_response(),
2758        Err(e) => {
2759            tracing::error!("handler error: {e}");
2760            (
2761                StatusCode::INTERNAL_SERVER_ERROR,
2762                Json(json!({"error": "internal server error"})),
2763            )
2764                .into_response()
2765        }
2766    }
2767}
2768
2769pub async fn get_links(State(state): State<Db>, Path(id): Path<String>) -> impl IntoResponse {
2770    if let Err(e) = validate::validate_id(&id) {
2771        return (
2772            StatusCode::BAD_REQUEST,
2773            Json(json!({"error": e.to_string()})),
2774        )
2775            .into_response();
2776    }
2777    let lock = state.lock().await;
2778    match db::get_links(&lock.0, &id) {
2779        Ok(links) => Json(json!({"links": links})).into_response(),
2780        Err(e) => {
2781            tracing::error!("handler error: {e}");
2782            (
2783                StatusCode::INTERNAL_SERVER_ERROR,
2784                Json(json!({"error": "internal server error"})),
2785            )
2786                .into_response()
2787        }
2788    }
2789}
2790
2791pub async fn get_stats(State(state): State<Db>) -> impl IntoResponse {
2792    let lock = state.lock().await;
2793    match db::stats(&lock.0, &lock.1) {
2794        Ok(s) => Json(json!(s)).into_response(),
2795        Err(e) => {
2796            tracing::error!("handler error: {e}");
2797            (
2798                StatusCode::INTERNAL_SERVER_ERROR,
2799                Json(json!({"error": "internal server error"})),
2800            )
2801                .into_response()
2802        }
2803    }
2804}
2805
2806pub async fn run_gc(State(state): State<Db>) -> impl IntoResponse {
2807    let lock = state.lock().await;
2808    match db::gc(&lock.0, lock.3) {
2809        Ok(n) => Json(json!({"expired_deleted": n})).into_response(),
2810        Err(e) => {
2811            tracing::error!("handler error: {e}");
2812            (
2813                StatusCode::INTERNAL_SERVER_ERROR,
2814                Json(json!({"error": "internal server error"})),
2815            )
2816                .into_response()
2817        }
2818    }
2819}
2820
2821pub async fn export_memories(State(state): State<Db>) -> impl IntoResponse {
2822    let lock = state.lock().await;
2823    match (db::export_all(&lock.0), db::export_links(&lock.0)) {
2824        (Ok(memories), Ok(links)) => {
2825            let count = memories.len();
2826            Json(json!({"memories": memories, "links": links, "count": count, "exported_at": Utc::now().to_rfc3339()})).into_response()
2827        }
2828        (Err(e), _) | (_, Err(e)) => {
2829            tracing::error!("export error: {e}");
2830            (
2831                StatusCode::INTERNAL_SERVER_ERROR,
2832                Json(json!({"error": "internal server error"})),
2833            )
2834                .into_response()
2835        }
2836    }
2837}
2838
2839pub async fn import_memories(
2840    State(state): State<Db>,
2841    Json(body): Json<ImportBody>,
2842) -> impl IntoResponse {
2843    if body.memories.len() > MAX_BULK_SIZE {
2844        return (
2845            StatusCode::BAD_REQUEST,
2846            Json(json!({"error": format!("import limited to {} memories", MAX_BULK_SIZE)})),
2847        )
2848            .into_response();
2849    }
2850    let lock = state.lock().await;
2851    let mut imported = 0usize;
2852    let mut errors = Vec::new();
2853    for mem in body.memories {
2854        if let Err(e) = validate::validate_memory(&mem) {
2855            errors.push(format!("{}: {}", mem.id, e));
2856            continue;
2857        }
2858        match db::insert(&lock.0, &mem) {
2859            Ok(_) => imported += 1,
2860            Err(e) => errors.push(format!("{}: {}", mem.id, e)),
2861        }
2862    }
2863    for link in body.links.unwrap_or_default() {
2864        if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
2865            continue;
2866        }
2867        let _ = db::create_link(&lock.0, &link.source_id, &link.target_id, &link.relation);
2868    }
2869    Json(json!({"imported": imported, "errors": errors})).into_response()
2870}
2871
2872#[derive(serde::Deserialize)]
2873pub struct ImportBody {
2874    pub memories: Vec<Memory>,
2875    #[serde(default)]
2876    pub links: Option<Vec<MemoryLink>>,
2877}
2878
2879#[derive(serde::Deserialize)]
2880pub struct ConsolidateBody {
2881    pub ids: Vec<String>,
2882    pub title: String,
2883    pub summary: String,
2884    #[serde(default = "default_ns")]
2885    pub namespace: String,
2886    #[serde(default)]
2887    pub tier: Option<Tier>,
2888    /// Optional `agent_id` for the consolidator (attributable on the result).
2889    /// If unset, resolved from `X-Agent-Id` header or per-request anonymous id.
2890    #[serde(default)]
2891    pub agent_id: Option<String>,
2892}
2893fn default_ns() -> String {
2894    "global".to_string()
2895}
2896
2897pub async fn consolidate_memories(
2898    State(app): State<AppState>,
2899    headers: HeaderMap,
2900    Json(body): Json<ConsolidateBody>,
2901) -> impl IntoResponse {
2902    if let Err(e) =
2903        validate::validate_consolidate(&body.ids, &body.title, &body.summary, &body.namespace)
2904    {
2905        return (
2906            StatusCode::BAD_REQUEST,
2907            Json(json!({"error": e.to_string()})),
2908        )
2909            .into_response();
2910    }
2911    let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
2912    let consolidator_agent_id =
2913        match crate::identity::resolve_http_agent_id(body.agent_id.as_deref(), header_agent_id) {
2914            Ok(id) => id,
2915            Err(e) => {
2916                return (
2917                    StatusCode::BAD_REQUEST,
2918                    Json(json!({"error": format!("invalid agent_id: {e}")})),
2919                )
2920                    .into_response();
2921            }
2922        };
2923    let lock = app.db.lock().await;
2924    let tier = body.tier.unwrap_or(Tier::Long);
2925    let source_ids = body.ids.clone();
2926    let consolidate_result = db::consolidate(
2927        &lock.0,
2928        &body.ids,
2929        &body.title,
2930        &body.summary,
2931        &body.namespace,
2932        &tier,
2933        "consolidation",
2934        &consolidator_agent_id,
2935    );
2936    // Read the newly consolidated memory back so we can fanout — must do
2937    // this inside the same lock window because db::consolidate deletes
2938    // the source rows as part of its transaction.
2939    let new_mem = match &consolidate_result {
2940        Ok(new_id) => db::get(&lock.0, new_id).ok().flatten(),
2941        Err(_) => None,
2942    };
2943    // v0.6.4-017 — G9 HTTP webhook parity. Fire `memory_consolidated`
2944    // after db::consolidate commits (mirrors mcp.rs:2723). The new
2945    // memory's id goes in the outer envelope; source ids in details.
2946    if let Ok(new_id) = &consolidate_result {
2947        let details = serde_json::to_value(crate::subscriptions::ConsolidatedEventDetails {
2948            source_ids: source_ids.clone(),
2949            source_count: source_ids.len(),
2950        })
2951        .ok();
2952        crate::subscriptions::dispatch_event_with_details(
2953            &lock.0,
2954            "memory_consolidated",
2955            new_id,
2956            &body.namespace,
2957            Some(&consolidator_agent_id),
2958            &lock.1,
2959            details,
2960        );
2961    }
2962    // Drop DB lock before fanning out — peers POST back to our sync_push
2963    // and we'd deadlock on the shared Mutex if we held it.
2964    drop(lock);
2965    match consolidate_result {
2966        Ok(new_id) => {
2967            // v0.6.2 (#326): propagate consolidation to peers so
2968            // `metadata.consolidated_from_agents` and the deleted sources
2969            // are in sync across the mesh.
2970            if let (Some(fed), Some(mem)) = (app.federation.as_ref(), new_mem) {
2971                match crate::federation::broadcast_consolidate_quorum(fed, &mem, &source_ids).await
2972                {
2973                    Ok(tracker) => {
2974                        if let Err(err) = crate::federation::finalise_quorum(&tracker) {
2975                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
2976                            return (
2977                                StatusCode::SERVICE_UNAVAILABLE,
2978                                [("Retry-After", "2")],
2979                                Json(serde_json::to_value(&payload).unwrap_or_default()),
2980                            )
2981                                .into_response();
2982                        }
2983                    }
2984                    Err(e) => {
2985                        tracing::warn!("consolidate fanout error (local committed): {e:?}");
2986                    }
2987                }
2988            }
2989            (
2990                StatusCode::CREATED,
2991                Json(json!({"id": new_id, "consolidated": body.ids.len()})),
2992            )
2993                .into_response()
2994        }
2995        Err(e) => {
2996            tracing::error!("handler error: {e}");
2997            (
2998                StatusCode::INTERNAL_SERVER_ERROR,
2999                Json(json!({"error": "internal server error"})),
3000            )
3001                .into_response()
3002        }
3003    }
3004}
3005
3006pub async fn bulk_create(
3007    State(app): State<AppState>,
3008    Json(bodies): Json<Vec<CreateMemory>>,
3009) -> impl IntoResponse {
3010    if bodies.len() > MAX_BULK_SIZE {
3011        return (
3012            StatusCode::BAD_REQUEST,
3013            Json(json!({"error": format!("bulk operations limited to {} items", MAX_BULK_SIZE)})),
3014        )
3015            .into_response();
3016    }
3017    let now = Utc::now();
3018    // Stage 1 — validate + insert locally. Collect the successfully-inserted
3019    // `Memory` values so we can fanout each one after we release the DB lock
3020    // (peers POST to our /sync/push and we'd deadlock on the Mutex if we
3021    // held it across the network call).
3022    let mut created_mems: Vec<Memory> = Vec::new();
3023    let mut errors: Vec<String> = Vec::new();
3024    {
3025        let lock = app.db.lock().await;
3026        for body in bodies {
3027            if let Err(e) = validate::validate_create(&body) {
3028                errors.push(format!("{}: {}", body.title, e));
3029                continue;
3030            }
3031            let expires_at = body.expires_at.or_else(|| {
3032                body.ttl_secs
3033                    .or(lock.2.ttl_for_tier(&body.tier))
3034                    .map(|s| (now + Duration::seconds(s)).to_rfc3339())
3035            });
3036            let mem = Memory {
3037                id: Uuid::new_v4().to_string(),
3038                tier: body.tier,
3039                namespace: body.namespace,
3040                title: body.title,
3041                content: body.content,
3042                tags: body.tags,
3043                priority: body.priority.clamp(1, 10),
3044                confidence: body.confidence.clamp(0.0, 1.0),
3045                source: body.source,
3046                access_count: 0,
3047                created_at: now.to_rfc3339(),
3048                updated_at: now.to_rfc3339(),
3049                last_accessed_at: None,
3050                expires_at,
3051                metadata: body.metadata,
3052            };
3053            match db::insert(&lock.0, &mem) {
3054                Ok(_) => created_mems.push(mem),
3055                Err(e) => errors.push(e.to_string()),
3056            }
3057        }
3058    }
3059    // Stage 2 — federation fanout, once per successfully-inserted row.
3060    //
3061    // v0.6.2 (S40): we run each row's `broadcast_store_quorum` *concurrently*
3062    // via `tokio::task::JoinSet`, bounded by a semaphore so we never have
3063    // more than `BULK_FANOUT_CONCURRENCY` in-flight fanouts at a time. The
3064    // prior form looped sequentially and paid one full ack-round-trip per
3065    // row — 500 rows × ~100ms = 50s, dwarfing the scenario's 20s settle
3066    // window so peers only received the first ~200 writes in time.
3067    //
3068    // Why a bound instead of unbounded? Unbounded (`JoinSet.spawn` for
3069    // each row at once) fires N × peers concurrent reqwest POSTs. At N=500
3070    // × 3 peers = 1500 concurrent TCP connects this exhausts ephemeral
3071    // ports and the reqwest client's connection pool, manifesting as
3072    // `network: error sending request` on most rows. A bound of 32
3073    // concurrent fanouts still pipelines the ack round-trip (100ms per
3074    // row × 500 / 32 ≈ 1.6s wall), well inside the 20s scenario budget.
3075    //
3076    // Each row's broadcast still uses the full quorum contract (local +
3077    // W-1 peer acks or 503). The semaphore only limits concurrency; it
3078    // does NOT weaken any single row's guarantees. Non-quorum errors
3079    // land in `errors` with the row id prefix, exactly as before. On a
3080    // quorum miss we keep going — a single row's miss must not abort the
3081    // other 499 the caller just paid for (bulk semantics, deliberately
3082    // weaker than `create_memory`'s 503 short-circuit).
3083    // Concurrency bound balances:
3084    //   - Speedup over sequential: N / bound × ack — need bound ≥ a few to
3085    //     clear 500 rows × 100ms ack inside the scenario's 20s settle.
3086    //   - Peer-side contention: every concurrent fanout lands a sync_push
3087    //     POST on the same SQLite Mutex on each peer. Too many in-flight
3088    //     serialize at the peer's DB lock and either timeout the quorum
3089    //     window or hit reqwest connection-pool / ephemeral-port limits
3090    //     on the leader side.
3091    //
3092    // 8 is a conservative compromise: 500 × 100ms / 8 ≈ 6.2s wall, comfortably
3093    // under the scenario's 20s budget while keeping the peer's per-writer
3094    // queue short enough to avoid timeouts under typical testbook load.
3095    // Tuned via the `BULK_FANOUT_CONCURRENCY` module constant.
3096    if let Some(fed) = app.federation.as_ref() {
3097        let sem = Arc::new(tokio::sync::Semaphore::new(BULK_FANOUT_CONCURRENCY));
3098        let mut joins: tokio::task::JoinSet<(String, Result<(), String>)> =
3099            tokio::task::JoinSet::new();
3100        for mem in &created_mems {
3101            let fed = fed.clone();
3102            let mem = mem.clone();
3103            let sem = sem.clone();
3104            joins.spawn(async move {
3105                // `acquire_owned` + a semaphore the task owns a clone of
3106                // means the permit lives for the task's lifetime — it's
3107                // released only when the task completes. A closed
3108                // semaphore would be a bug; surface it via the error
3109                // channel and keep going.
3110                let Ok(_permit) = sem.acquire_owned().await else {
3111                    return (mem.id.clone(), Err("fanout semaphore closed".to_string()));
3112                };
3113                let id = mem.id.clone();
3114                let outcome = match crate::federation::broadcast_store_quorum(&fed, &mem).await {
3115                    Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
3116                        Ok(_) => Ok(()),
3117                        Err(err) => Err(err.to_string()),
3118                    },
3119                    Err(e) => {
3120                        tracing::warn!(
3121                            "bulk_create: fanout for {id} failed (local committed): {e:?}"
3122                        );
3123                        Ok(())
3124                    }
3125                };
3126                (id, outcome)
3127            });
3128        }
3129        while let Some(res) = joins.join_next().await {
3130            match res {
3131                Ok((id, Err(err))) => errors.push(format!("{id}: {err}")),
3132                Ok((_, Ok(()))) => {}
3133                Err(e) => tracing::warn!("bulk_create: fanout task join error: {e:?}"),
3134            }
3135        }
3136
3137        // v0.6.2 Patch 2 (S40): terminal catchup batch. Per-row quorum
3138        // met above, but the post-quorum detach path — even with
3139        // retry-once in `post_and_classify` — can still leave a peer
3140        // one row behind under sustained SQLite-mutex contention (v3r26
3141        // hermes-tls 499/500 and v3r27 ironclaw-off 499/500 both tripped
3142        // the scenario despite the retry). A single batched `sync_push`
3143        // per peer with every committed row closes the gap: peer's
3144        // `insert_if_newer` no-ops rows it already has and applies the
3145        // missing one. O(1) extra POST per peer vs O(N) per-row retries.
3146        //
3147        // Errors are logged and folded into the response `errors` array
3148        // but do NOT fail the bulk write — quorum was already met, so
3149        // the HTTP contract is satisfied. The catchup only strengthens
3150        // eventual consistency within the scenario settle window.
3151        if !created_mems.is_empty() {
3152            let catchup_errors = crate::federation::bulk_catchup_push(fed, &created_mems).await;
3153            for (peer_id, err) in catchup_errors {
3154                errors.push(format!("catchup to {peer_id}: {err}"));
3155            }
3156        }
3157    }
3158    Json(json!({"created": created_mems.len(), "errors": errors})).into_response()
3159}
3160
3161// ---------------------------------------------------------------------------
3162// Archive endpoints
3163// ---------------------------------------------------------------------------
3164
3165#[derive(Debug, Deserialize)]
3166pub struct ArchiveListQuery {
3167    pub namespace: Option<String>,
3168    #[serde(default = "default_archive_limit")]
3169    pub limit: Option<usize>,
3170    #[serde(default)]
3171    pub offset: Option<usize>,
3172}
3173
3174#[allow(clippy::unnecessary_wraps)]
3175fn default_archive_limit() -> Option<usize> {
3176    Some(50)
3177}
3178
3179pub async fn list_archive(
3180    State(state): State<Db>,
3181    Query(q): Query<ArchiveListQuery>,
3182) -> impl IntoResponse {
3183    // Ultrareview #350: validate limit range. `usize` already precludes
3184    // negative values at the serde layer, but `limit=0` silently
3185    // returned an empty page — indistinguishable from "no results".
3186    // Require 1..=1000 and reject 0 with a specific error.
3187    if matches!(q.limit, Some(0)) {
3188        return (
3189            StatusCode::BAD_REQUEST,
3190            Json(json!({"error": "limit must be >= 1"})),
3191        )
3192            .into_response();
3193    }
3194    let lock = state.lock().await;
3195    let limit = q.limit.unwrap_or(50).clamp(1, 1000);
3196    let offset = q.offset.unwrap_or(0);
3197    match db::list_archived(&lock.0, q.namespace.as_deref(), limit, offset) {
3198        Ok(items) => Json(json!({"archived": items, "count": items.len()})).into_response(),
3199        Err(e) => {
3200            tracing::error!("handler error: {e}");
3201            (
3202                StatusCode::INTERNAL_SERVER_ERROR,
3203                Json(json!({"error": "internal server error"})),
3204            )
3205                .into_response()
3206        }
3207    }
3208}
3209
3210pub async fn restore_archive(
3211    State(app): State<AppState>,
3212    Path(id): Path<String>,
3213) -> impl IntoResponse {
3214    if let Err(e) = validate::validate_id(&id) {
3215        return (
3216            StatusCode::BAD_REQUEST,
3217            Json(json!({"error": e.to_string()})),
3218        )
3219            .into_response();
3220    }
3221    let restored = {
3222        let lock = app.db.lock().await;
3223        match db::restore_archived(&lock.0, &id) {
3224            Ok(v) => v,
3225            Err(e) => {
3226                tracing::error!("handler error: {e}");
3227                return (
3228                    StatusCode::INTERNAL_SERVER_ERROR,
3229                    Json(json!({"error": "internal server error"})),
3230                )
3231                    .into_response();
3232            }
3233        }
3234    };
3235    if !restored {
3236        return (
3237            StatusCode::NOT_FOUND,
3238            Json(json!({"error": "not found in archive"})),
3239        )
3240            .into_response();
3241    }
3242
3243    // v0.6.2 (S29): broadcast the restore to peers so they move the row
3244    // from `archived_memories` → `memories` in lockstep. Without this, a
3245    // POST /api/v1/archive/{id}/restore on node-1 leaves node-2..4 with
3246    // the row still archived, so node-4 never sees M1 re-enter the active
3247    // set (the testbook-v3 S29 assertion). Same posture as
3248    // `archive_by_ids`: on a quorum miss we short-circuit with 503 so
3249    // operators can retry.
3250    if let Some(fed) = app.federation.as_ref() {
3251        match crate::federation::broadcast_restore_quorum(fed, &id).await {
3252            Ok(tracker) => {
3253                if let Err(err) = crate::federation::finalise_quorum(&tracker) {
3254                    let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
3255                    return (
3256                        StatusCode::SERVICE_UNAVAILABLE,
3257                        [("Retry-After", "2")],
3258                        Json(serde_json::to_value(&payload).unwrap_or_default()),
3259                    )
3260                        .into_response();
3261                }
3262            }
3263            Err(e) => {
3264                // Local commit already landed — sync-daemon catches
3265                // stragglers. Same posture as `fanout_or_503`.
3266                tracing::warn!("restore fanout error (local committed): {e:?}");
3267            }
3268        }
3269    }
3270
3271    Json(json!({"restored": true, "id": id})).into_response()
3272}
3273
3274#[derive(Debug, Deserialize)]
3275pub struct PurgeQuery {
3276    pub older_than_days: Option<i64>,
3277}
3278
3279pub async fn purge_archive(
3280    State(state): State<Db>,
3281    Query(q): Query<PurgeQuery>,
3282) -> impl IntoResponse {
3283    let lock = state.lock().await;
3284    match db::purge_archive(&lock.0, q.older_than_days) {
3285        Ok(n) => Json(json!({"purged": n})).into_response(),
3286        Err(e) => {
3287            tracing::error!("handler error: {e}");
3288            (
3289                StatusCode::INTERNAL_SERVER_ERROR,
3290                Json(json!({"error": "internal server error"})),
3291            )
3292                .into_response()
3293        }
3294    }
3295}
3296
3297pub async fn archive_stats(State(state): State<Db>) -> impl IntoResponse {
3298    let lock = state.lock().await;
3299    match db::archive_stats(&lock.0) {
3300        Ok(archive_stats) => Json(archive_stats).into_response(),
3301        Err(e) => {
3302            tracing::error!("handler error: {e}");
3303            (
3304                StatusCode::INTERNAL_SERVER_ERROR,
3305                Json(json!({"error": "internal server error"})),
3306            )
3307                .into_response()
3308        }
3309    }
3310}
3311
3312/// Request body for `POST /api/v1/archive` — S29 explicit archive.
3313#[derive(Debug, Deserialize)]
3314pub struct ArchiveByIdsBody {
3315    pub ids: Vec<String>,
3316    #[serde(default)]
3317    pub reason: Option<String>,
3318}
3319
3320/// POST /api/v1/archive — explicit archive of the given memory ids
3321/// (S29). For each id:
3322///   1. Call `db::archive_memory` locally to soft-move the row.
3323///   2. If federation is configured, broadcast via
3324///      `broadcast_archive_quorum` so peers land in the same terminal
3325///      state (row out of `memories`, row into `archived_memories`).
3326///
3327/// On a quorum miss for ANY id, short-circuit with 503 via the shared
3328/// `fanout_or_503`-style payload. This matches the posture of the
3329/// delete + consolidate fanout endpoints.
3330///
3331/// Response body:
3332/// ```json
3333/// {"archived": [id1, id2], "missing": [id3], "count": 2}
3334/// ```
3335/// where `missing` enumerates ids that had no live row locally (common
3336/// during retries). The response never includes content/metadata — use
3337/// `GET /api/v1/archive` to list archive entries.
3338pub async fn archive_by_ids(
3339    State(app): State<AppState>,
3340    Json(body): Json<ArchiveByIdsBody>,
3341) -> impl IntoResponse {
3342    // Bound the batch the same way bulk_create / sync_push do.
3343    if body.ids.len() > MAX_BULK_SIZE {
3344        return (
3345            StatusCode::BAD_REQUEST,
3346            Json(json!({"error": format!("archive limited to {} ids per request", MAX_BULK_SIZE)})),
3347        )
3348            .into_response();
3349    }
3350    // Validate all ids up-front so we never start mutating on a bad batch.
3351    for id in &body.ids {
3352        if let Err(e) = validate::validate_id(id) {
3353            return (
3354                StatusCode::BAD_REQUEST,
3355                Json(json!({"error": format!("invalid id {id}: {e}")})),
3356            )
3357                .into_response();
3358        }
3359    }
3360    let reason = body.reason.as_deref().unwrap_or("archive").to_string();
3361    let mut archived: Vec<String> = Vec::new();
3362    let mut missing: Vec<String> = Vec::new();
3363
3364    for id in &body.ids {
3365        // Local archive. Hold the lock only across this one call per id so
3366        // we can release it before a potentially slow network fanout.
3367        let moved = {
3368            let lock = app.db.lock().await;
3369            match db::archive_memory(&lock.0, id, Some(&reason)) {
3370                Ok(v) => v,
3371                Err(e) => {
3372                    tracing::error!("archive_by_ids: archive_memory({id}) failed: {e}");
3373                    return (
3374                        StatusCode::INTERNAL_SERVER_ERROR,
3375                        Json(json!({"error": "internal server error"})),
3376                    )
3377                        .into_response();
3378                }
3379            }
3380        };
3381        if !moved {
3382            // Row wasn't live locally — record as missing but keep going.
3383            // Do NOT fan out (peers can't know to archive from a row they
3384            // may have under a different state; the originator's local
3385            // state is the trigger).
3386            missing.push(id.clone());
3387            continue;
3388        }
3389
3390        // Fanout. Mirror the shape used by the other
3391        // quorum-backed write endpoints (delete, consolidate) — on a
3392        // miss, surface the `quorum_not_met` payload with 503 + Retry-After.
3393        if let Some(fed) = app.federation.as_ref() {
3394            match crate::federation::broadcast_archive_quorum(fed, id).await {
3395                Ok(tracker) => {
3396                    if let Err(err) = crate::federation::finalise_quorum(&tracker) {
3397                        let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
3398                        return (
3399                            StatusCode::SERVICE_UNAVAILABLE,
3400                            [("Retry-After", "2")],
3401                            Json(serde_json::to_value(&payload).unwrap_or_default()),
3402                        )
3403                            .into_response();
3404                    }
3405                }
3406                Err(e) => {
3407                    // Local commit already landed — sync-daemon catches
3408                    // stragglers. Same posture as `fanout_or_503`.
3409                    tracing::warn!("archive fanout error (local committed): {e:?}");
3410                }
3411            }
3412        }
3413        archived.push(id.clone());
3414    }
3415
3416    (
3417        StatusCode::OK,
3418        Json(json!({
3419            "archived": archived,
3420            "missing": missing,
3421            "count": archived.len(),
3422            "reason": reason,
3423        })),
3424    )
3425        .into_response()
3426}
3427
3428// ---------------------------------------------------------------------------
3429// Phase 3 foundation (issue #224) — HTTP sync endpoints.
3430//
3431// These ship in v0.6.0 GA as SKELETONS running today's timestamp-aware merge
3432// (`db::insert_if_newer`). Field-level CRDT-lite merge rules, streaming,
3433// resume-on-interrupt, and per-peer auth tokens are v0.8.0 targets.
3434// ---------------------------------------------------------------------------
3435
3436/// Request body for `POST /api/v1/sync/push`.
3437#[derive(Deserialize)]
3438pub struct SyncPushBody {
3439    /// Claimed `agent_id` of the peer pushing data. Recorded in
3440    /// `sync_state` for vector clock advancement. Treated as identity
3441    /// only (not attestation) — same NHI model as every other write.
3442    pub sender_agent_id: String,
3443    /// Vector clock the sender had at push time. Foundation accepts it
3444    /// and stores the latest-seen timestamp; full clock reconciliation
3445    /// lands with Task 3a.1.
3446    #[serde(default)]
3447    #[allow(dead_code)] // Consumed by Task 3a.1 CRDT-lite; shipped now for wire compat.
3448    pub sender_clock: crate::models::VectorClock,
3449    /// Memories the sender is offering. Applied via the existing
3450    /// timestamp-aware merge (`insert_if_newer`).
3451    pub memories: Vec<Memory>,
3452    /// Memory IDs the sender has deleted and wants propagated. Applied
3453    /// via `db::delete`. v0.6.0.1: simple remove (no tombstone row); a
3454    /// concurrent newer `insert_if_newer` from another peer could revive
3455    /// the row — a Last-Writer-Wins quirk we live with until v0.7's
3456    /// CRDT-lite tombstone table lands. In the common 4-node mesh, the
3457    /// same delete reaches every peer well before any revival window.
3458    #[serde(default)]
3459    pub deletions: Vec<String>,
3460    /// v0.6.2 (S29): memory IDs the sender has explicitly archived and
3461    /// wants propagated. Applied via `db::archive_memory` — a soft move
3462    /// from `memories` to `archived_memories`. Missing-on-peer IDs no-op.
3463    /// Distinct from `deletions`, which is a hard DELETE.
3464    #[serde(default)]
3465    pub archives: Vec<String>,
3466    /// v0.6.2 (S29): memory IDs the sender has restored from archive and
3467    /// wants propagated. Applied via `db::restore_archived` — moves the
3468    /// row from `archived_memories` back into `memories`. The inverse of
3469    /// `archives`. Missing-on-peer IDs (no row in the peer's archive
3470    /// table, or a live row already exists) no-op so replays are safe.
3471    #[serde(default)]
3472    pub restores: Vec<String>,
3473    /// v0.6.2 (#325): memory links the sender wants propagated. Applied
3474    /// via `db::create_link` on each peer. Duplicates are a no-op thanks
3475    /// to the unique `(source_id, target_id, relation)` constraint on
3476    /// `memory_links`.
3477    #[serde(default)]
3478    pub links: Vec<MemoryLink>,
3479    /// v0.6.2 (S34): pending-action rows the sender wants propagated.
3480    /// Applied via `db::upsert_pending_action` — preserves the originator's
3481    /// id + status + approvals so the cluster agrees on pending state.
3482    /// Without this, `POST /api/v1/pending/{id}/approve` on a peer 404s
3483    /// because the row only exists on the originator.
3484    #[serde(default)]
3485    pub pendings: Vec<crate::models::PendingAction>,
3486    /// v0.6.2 (S34): pending-action decisions the sender wants propagated
3487    /// so approve/reject on any node lands consistently. Applied via
3488    /// `db::decide_pending_action` — already-decided rows no-op, replay-safe.
3489    #[serde(default)]
3490    pub pending_decisions: Vec<crate::models::PendingDecision>,
3491    /// v0.6.2 (S35): namespace-standard meta rows the sender wants
3492    /// propagated. Applied via `db::set_namespace_standard(conn, ns,
3493    /// standard_id, parent.as_deref())` so the peer's inheritance-chain
3494    /// walk uses the originator's explicit parent (not a locally
3495    /// auto-detected one).
3496    #[serde(default)]
3497    pub namespace_meta: Vec<crate::models::NamespaceMetaEntry>,
3498    /// v0.6.2 (S35 follow-up): namespaces whose standard the sender has
3499    /// *cleared* and wants propagated. Applied via `db::clear_namespace_standard`
3500    /// — missing-on-peer namespaces no-op so replays are safe. Without
3501    /// this, alice clearing a standard on node-1 left the row visible on
3502    /// node-2's peer, breaking cross-peer rule-lifecycle assertions.
3503    #[serde(default)]
3504    pub namespace_meta_clears: Vec<String>,
3505    /// Preview mode — classify and count, do not write.
3506    #[serde(default)]
3507    pub dry_run: bool,
3508}
3509
3510#[derive(Deserialize)]
3511pub struct SyncSinceQuery {
3512    /// Return memories with `updated_at > since`. Absent = full snapshot.
3513    pub since: Option<String>,
3514    /// Pagination cap. Defaults to 500.
3515    pub limit: Option<usize>,
3516    /// Caller's claimed `agent_id`; optional but recorded in `sync_state`
3517    /// so the caller can later push incremental updates.
3518    pub peer: Option<String>,
3519}
3520
3521#[allow(clippy::too_many_lines)]
3522pub async fn sync_push(
3523    State(app): State<AppState>,
3524    headers: HeaderMap,
3525    Json(body): Json<SyncPushBody>,
3526) -> impl IntoResponse {
3527    let state = app.db.clone();
3528    if let Err(e) = validate::validate_agent_id(&body.sender_agent_id) {
3529        return (
3530            StatusCode::BAD_REQUEST,
3531            Json(json!({"error": format!("invalid sender_agent_id: {e}")})),
3532        )
3533            .into_response();
3534    }
3535    // Cap memories per push, matching the bulk-create limit. Without
3536    // this a malicious peer with a valid mTLS cert could flood the
3537    // receiver and bottleneck the shared SQLite Mutex (red-team #242).
3538    if body.memories.len() > MAX_BULK_SIZE {
3539        return (
3540            StatusCode::BAD_REQUEST,
3541            Json(json!({
3542                "error": format!("sync_push limited to {} memories per request", MAX_BULK_SIZE)
3543            })),
3544        )
3545            .into_response();
3546    }
3547    if body.deletions.len() > MAX_BULK_SIZE {
3548        return (
3549            StatusCode::BAD_REQUEST,
3550            Json(json!({
3551                "error": format!("sync_push limited to {} deletions per request", MAX_BULK_SIZE)
3552            })),
3553        )
3554            .into_response();
3555    }
3556    if body.archives.len() > MAX_BULK_SIZE {
3557        return (
3558            StatusCode::BAD_REQUEST,
3559            Json(json!({
3560                "error": format!("sync_push limited to {} archives per request", MAX_BULK_SIZE)
3561            })),
3562        )
3563            .into_response();
3564    }
3565    if body.restores.len() > MAX_BULK_SIZE {
3566        return (
3567            StatusCode::BAD_REQUEST,
3568            Json(json!({
3569                "error": format!("sync_push limited to {} restores per request", MAX_BULK_SIZE)
3570            })),
3571        )
3572            .into_response();
3573    }
3574    if body.pendings.len() > MAX_BULK_SIZE {
3575        return (
3576            StatusCode::BAD_REQUEST,
3577            Json(json!({
3578                "error": format!("sync_push limited to {} pendings per request", MAX_BULK_SIZE)
3579            })),
3580        )
3581            .into_response();
3582    }
3583    if body.pending_decisions.len() > MAX_BULK_SIZE {
3584        return (
3585            StatusCode::BAD_REQUEST,
3586            Json(json!({
3587                "error": format!(
3588                    "sync_push limited to {} pending_decisions per request",
3589                    MAX_BULK_SIZE
3590                )
3591            })),
3592        )
3593            .into_response();
3594    }
3595    if body.namespace_meta.len() > MAX_BULK_SIZE {
3596        return (
3597            StatusCode::BAD_REQUEST,
3598            Json(json!({
3599                "error": format!(
3600                    "sync_push limited to {} namespace_meta per request",
3601                    MAX_BULK_SIZE
3602                )
3603            })),
3604        )
3605            .into_response();
3606    }
3607    if body.namespace_meta_clears.len() > MAX_BULK_SIZE {
3608        return (
3609            StatusCode::BAD_REQUEST,
3610            Json(json!({
3611                "error": format!(
3612                    "sync_push limited to {} namespace_meta_clears per request",
3613                    MAX_BULK_SIZE
3614                )
3615            })),
3616        )
3617            .into_response();
3618    }
3619    // Receiver's local identity — default to the caller-supplied header,
3620    // fall back to the anonymous placeholder. Recorded in sync_state rows.
3621    let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
3622    let local_agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
3623        Ok(id) => id,
3624        Err(e) => {
3625            return (
3626                StatusCode::BAD_REQUEST,
3627                Json(json!({"error": format!("invalid x-agent-id: {e}")})),
3628            )
3629                .into_response();
3630        }
3631    };
3632
3633    let lock = state.lock().await;
3634    let mut applied = 0usize;
3635    let mut noop = 0usize;
3636    let mut skipped = 0usize;
3637    let mut deleted = 0usize;
3638    let mut archived = 0usize;
3639    let mut restored = 0usize;
3640    let mut latest_seen: Option<String> = None;
3641
3642    // v0.6.0.1 (#322): peers that apply a synced memory must also refresh
3643    // their embedding + HNSW index so downstream semantic recall surfaces
3644    // the row. Without this, scenario-18 observed a2a-hermes r14 black-hole
3645    // pattern: substrate CRUD fanout works, but semantic recall on peers
3646    // silently misses propagated writes.
3647    //
3648    // Collect rows that need an embedding refresh and apply AFTER we drop
3649    // the DB lock (embedder is CPU-heavy; holding the Mutex across that
3650    // would serialize unrelated writers for hundreds of ms).
3651    let mut embedding_refresh: Vec<(String, String)> = Vec::new();
3652    for mem in &body.memories {
3653        if let Err(e) = validate::validate_memory(mem) {
3654            tracing::warn!("sync_push: skipping memory {} ({}): {e}", mem.id, mem.title);
3655            skipped += 1;
3656            continue;
3657        }
3658        if latest_seen
3659            .as_deref()
3660            .is_none_or(|current| mem.updated_at.as_str() > current)
3661        {
3662            latest_seen = Some(mem.updated_at.clone());
3663        }
3664        if body.dry_run {
3665            noop += 1;
3666            continue;
3667        }
3668        match db::insert_if_newer(&lock.0, mem) {
3669            Ok(actual_id) => {
3670                applied += 1;
3671                embedding_refresh.push((actual_id, format!("{} {}", mem.title, mem.content)));
3672            }
3673            Err(e) => {
3674                tracing::warn!("sync_push: insert_if_newer failed for {}: {e}", mem.id);
3675                skipped += 1;
3676            }
3677        }
3678    }
3679
3680    // Process deletions (v0.6.0.1 — scenario 10 fanout). Invalid ids are
3681    // skipped silently; missing rows count as no-op. Peers that have
3682    // already GC'd the row see identical post-state.
3683    for del_id in &body.deletions {
3684        if validate::validate_id(del_id).is_err() {
3685            skipped += 1;
3686            continue;
3687        }
3688        if body.dry_run {
3689            noop += 1;
3690            continue;
3691        }
3692        match db::delete(&lock.0, del_id) {
3693            Ok(true) => deleted += 1,
3694            Ok(false) => noop += 1,
3695            Err(e) => {
3696                tracing::warn!("sync_push: delete failed for {del_id}: {e}");
3697                skipped += 1;
3698            }
3699        }
3700    }
3701
3702    // v0.6.2 (S29): process explicit archives. Soft-move from `memories`
3703    // to `archived_memories` — distinct from deletions which hard-delete.
3704    // Missing rows count as no-op (peer may have already archived or
3705    // never received the original write).
3706    for arch_id in &body.archives {
3707        if validate::validate_id(arch_id).is_err() {
3708            skipped += 1;
3709            continue;
3710        }
3711        if body.dry_run {
3712            noop += 1;
3713            continue;
3714        }
3715        match db::archive_memory(&lock.0, arch_id, Some("sync_push")) {
3716            Ok(true) => archived += 1,
3717            Ok(false) => noop += 1,
3718            Err(e) => {
3719                tracing::warn!("sync_push: archive_memory failed for {arch_id}: {e}");
3720                skipped += 1;
3721            }
3722        }
3723    }
3724
3725    // v0.6.2 (S29): process explicit restores — the inverse of archives.
3726    // Move the row from `archived_memories` back into `memories`.
3727    // No-op posture matches archives: missing rows (peer hasn't received
3728    // the archive, or the row is already live) count as noop so replays
3729    // and out-of-order restore/archive pairs don't error.
3730    for res_id in &body.restores {
3731        if validate::validate_id(res_id).is_err() {
3732            skipped += 1;
3733            continue;
3734        }
3735        if body.dry_run {
3736            noop += 1;
3737            continue;
3738        }
3739        match db::restore_archived(&lock.0, res_id) {
3740            Ok(true) => restored += 1,
3741            Ok(false) => noop += 1,
3742            Err(e) => {
3743                tracing::warn!("sync_push: restore_archived failed for {res_id}: {e}");
3744                skipped += 1;
3745            }
3746        }
3747    }
3748
3749    // v0.6.2 (#325): process incoming links. Duplicates are expected on
3750    // retry / re-sync and collapse to a no-op via the unique index on
3751    // (source_id, target_id, relation). Invalid ids are skipped silently
3752    // — same posture as deletions.
3753    let mut links_applied = 0usize;
3754    for link in &body.links {
3755        if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
3756            skipped += 1;
3757            continue;
3758        }
3759        if body.dry_run {
3760            noop += 1;
3761            continue;
3762        }
3763        match db::create_link(&lock.0, &link.source_id, &link.target_id, &link.relation) {
3764            Ok(()) => links_applied += 1,
3765            Err(e) => {
3766                tracing::warn!(
3767                    "sync_push: create_link failed ({} -> {} / {}): {e}",
3768                    link.source_id,
3769                    link.target_id,
3770                    link.relation
3771                );
3772                skipped += 1;
3773            }
3774        }
3775    }
3776
3777    // v0.6.2 (S34): process incoming pending-action rows. Uses
3778    // `upsert_pending_action` so replays / races converge on the
3779    // originator's canonical row. Invalid ids skipped silently.
3780    let mut pendings_applied = 0usize;
3781    for pa in &body.pendings {
3782        if validate::validate_id(&pa.id).is_err() {
3783            skipped += 1;
3784            continue;
3785        }
3786        if body.dry_run {
3787            noop += 1;
3788            continue;
3789        }
3790        match db::upsert_pending_action(&lock.0, pa) {
3791            Ok(()) => pendings_applied += 1,
3792            Err(e) => {
3793                tracing::warn!("sync_push: upsert_pending_action failed for {}: {e}", pa.id);
3794                skipped += 1;
3795            }
3796        }
3797    }
3798
3799    // v0.6.2 (S34): process incoming pending-action decisions. No-op on
3800    // already-decided rows; that's the steady-state when the originator
3801    // and this peer both saw the decision. Rejected decisions still
3802    // transition status so retries on either side see `status != 'pending'`.
3803    let mut pending_decisions_applied = 0usize;
3804    for dec in &body.pending_decisions {
3805        if validate::validate_id(&dec.id).is_err() {
3806            skipped += 1;
3807            continue;
3808        }
3809        if body.dry_run {
3810            noop += 1;
3811            continue;
3812        }
3813        match db::decide_pending_action(&lock.0, &dec.id, dec.approved, &dec.decider) {
3814            Ok(true) => {
3815                pending_decisions_applied += 1;
3816                // On approve, replay the pending payload so the target
3817                // write (store/delete/promote) actually lands on this
3818                // peer — matches the originator's post-approve state.
3819                if dec.approved {
3820                    match db::execute_pending_action(&lock.0, &dec.id) {
3821                        Ok(_) => {}
3822                        Err(e) => {
3823                            tracing::warn!(
3824                                "sync_push: execute_pending_action failed for {}: {e}",
3825                                dec.id
3826                            );
3827                        }
3828                    }
3829                }
3830            }
3831            Ok(false) => noop += 1, // already decided — converged state
3832            Err(e) => {
3833                tracing::warn!(
3834                    "sync_push: decide_pending_action failed for {}: {e}",
3835                    dec.id
3836                );
3837                skipped += 1;
3838            }
3839        }
3840    }
3841
3842    // v0.6.2 (S35): process incoming namespace_meta rows. Applies via
3843    // `set_namespace_standard` so the peer's inheritance-chain walk has
3844    // the originator's explicit parent link. The standard memory itself
3845    // rides on the same push via `memories` (or arrived earlier through
3846    // `broadcast_store_quorum`); the namespace-meta row closes the gap.
3847    let mut namespace_meta_applied = 0usize;
3848    for entry in &body.namespace_meta {
3849        if validate::validate_namespace(&entry.namespace).is_err()
3850            || validate::validate_id(&entry.standard_id).is_err()
3851        {
3852            skipped += 1;
3853            continue;
3854        }
3855        if body.dry_run {
3856            noop += 1;
3857            continue;
3858        }
3859        match db::set_namespace_standard(
3860            &lock.0,
3861            &entry.namespace,
3862            &entry.standard_id,
3863            entry.parent_namespace.as_deref(),
3864        ) {
3865            Ok(()) => namespace_meta_applied += 1,
3866            Err(e) => {
3867                tracing::warn!(
3868                    "sync_push: set_namespace_standard failed for {}: {e}",
3869                    entry.namespace
3870                );
3871                skipped += 1;
3872            }
3873        }
3874    }
3875
3876    // v0.6.2 (S35 follow-up): process incoming namespace_meta_clears. Applies
3877    // via `db::clear_namespace_standard` so the peer drops its meta row and
3878    // subsequent `get_standard` returns empty. Missing-on-peer namespaces
3879    // no-op (`changed == 0`) — replays are safe.
3880    let mut namespace_meta_cleared = 0usize;
3881    for ns in &body.namespace_meta_clears {
3882        if validate::validate_namespace(ns).is_err() {
3883            skipped += 1;
3884            continue;
3885        }
3886        if body.dry_run {
3887            noop += 1;
3888            continue;
3889        }
3890        match db::clear_namespace_standard(&lock.0, ns) {
3891            Ok(true) => namespace_meta_cleared += 1,
3892            Ok(false) => noop += 1,
3893            Err(e) => {
3894                tracing::warn!("sync_push: clear_namespace_standard failed for {ns}: {e}");
3895                skipped += 1;
3896            }
3897        }
3898    }
3899
3900    // Advance the vector clock with the highest `updated_at` we observed.
3901    // Skipped in dry-run mode since the caller is only previewing.
3902    if !body.dry_run
3903        && let Some(at) = latest_seen.as_deref()
3904        && let Err(e) = db::sync_state_observe(&lock.0, &local_agent_id, &body.sender_agent_id, at)
3905    {
3906        tracing::warn!("sync_push: sync_state_observe failed: {e}");
3907    }
3908
3909    // v0.6.0.1 (#322): regenerate embeddings for applied rows so peer-side
3910    // semantic recall surfaces the propagated memories. Without this,
3911    // scenario-18 observed the a2a-hermes r14 black-hole pattern:
3912    // substrate CRUD fanout works, but semantic recall on peers misses.
3913    //
3914    // Embedding + set_embedding are serialized under the existing DB lock;
3915    // HNSW updates happen after we release the lock to avoid contention.
3916    let mut hnsw_updates: Vec<(String, Vec<f32>)> = Vec::new();
3917    if !body.dry_run
3918        && !embedding_refresh.is_empty()
3919        && let Some(emb) = app.embedder.as_ref().as_ref()
3920    {
3921        for (id, text) in &embedding_refresh {
3922            match emb.embed(text) {
3923                Ok(vec) => {
3924                    if let Err(e) = db::set_embedding(&lock.0, id, &vec) {
3925                        tracing::warn!("sync_push: set_embedding failed for {id}: {e}");
3926                        continue;
3927                    }
3928                    hnsw_updates.push((id.clone(), vec));
3929                }
3930                Err(e) => {
3931                    tracing::warn!("sync_push: embed failed for {id}: {e}");
3932                }
3933            }
3934        }
3935    }
3936
3937    // Receiver's current clock, returned so the sender can learn which
3938    // peers the receiver has seen. Phase 3 Task 3a.1 will use this to
3939    // short-circuit redundant pushes.
3940    let receiver_clock = db::sync_state_load(&lock.0, &local_agent_id)
3941        .unwrap_or_else(|_| crate::models::VectorClock::default());
3942
3943    // Release DB lock before touching the HNSW index — the vector index
3944    // has its own mutex and holding both serializes unrelated writers.
3945    drop(lock);
3946    if !hnsw_updates.is_empty() {
3947        let mut idx_lock = app.vector_index.lock().await;
3948        if let Some(idx) = idx_lock.as_mut() {
3949            for (id, vec) in hnsw_updates {
3950                idx.remove(&id);
3951                idx.insert(id, vec);
3952            }
3953        }
3954    }
3955
3956    (
3957        StatusCode::OK,
3958        Json(json!({
3959            "applied": applied,
3960            "deleted": deleted,
3961            "archived": archived,
3962            "restored": restored,
3963            "links_applied": links_applied,
3964            "pendings_applied": pendings_applied,
3965            "pending_decisions_applied": pending_decisions_applied,
3966            "namespace_meta_applied": namespace_meta_applied,
3967            "namespace_meta_cleared": namespace_meta_cleared,
3968            "noop": noop,
3969            "skipped": skipped,
3970            "dry_run": body.dry_run,
3971            "receiver_agent_id": local_agent_id,
3972            "receiver_clock": receiver_clock,
3973        })),
3974    )
3975        .into_response()
3976}
3977
3978pub async fn sync_since(
3979    State(state): State<Db>,
3980    headers: HeaderMap,
3981    Query(q): Query<SyncSinceQuery>,
3982) -> impl IntoResponse {
3983    // Validate `since` parses as RFC 3339 BEFORE hitting the DB so a
3984    // garbage timestamp returns a clear 400 instead of a 200 with the
3985    // entire database (red-team #247).
3986    if let Some(ref s) = q.since
3987        && !s.is_empty()
3988        && chrono::DateTime::parse_from_rfc3339(s).is_err()
3989    {
3990        return (
3991            StatusCode::BAD_REQUEST,
3992            Json(json!({
3993                "error": "invalid `since` parameter — expected RFC 3339 timestamp"
3994            })),
3995        )
3996            .into_response();
3997    }
3998    let limit = q.limit.unwrap_or(500).min(10_000);
3999    let lock = state.lock().await;
4000    let mems = match db::memories_updated_since(&lock.0, q.since.as_deref(), limit) {
4001        Ok(v) => v,
4002        Err(e) => {
4003            tracing::error!("sync_since: {e}");
4004            return (
4005                StatusCode::INTERNAL_SERVER_ERROR,
4006                Json(json!({"error": "internal server error"})),
4007            )
4008                .into_response();
4009        }
4010    };
4011
4012    // Record the puller as a peer so subsequent incremental push/pull
4013    // pairs have a durable clock entry. Best-effort; don't fail the
4014    // response if the side-effect write fails.
4015    let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
4016    if let (Some(peer), Ok(local_agent_id)) = (
4017        q.peer.as_deref(),
4018        crate::identity::resolve_http_agent_id(None, header_agent_id),
4019    ) && validate::validate_agent_id(peer).is_ok()
4020        && let Some(last) = mems.last()
4021        && let Err(e) = db::sync_state_observe(&lock.0, &local_agent_id, peer, &last.updated_at)
4022    {
4023        tracing::debug!("sync_since: sync_state_observe failed: {e}");
4024    }
4025
4026    // S39 diagnostic echo (v0.6.2). The testbook scenario writes 6 rows
4027    // while peer-3 is suspended then queries `/sync/since?since=<ckpt>`
4028    // and expects the 6 back. When the count comes back 0, the scenario
4029    // can't tell whether:
4030    //   a) the server parsed `since` differently than expected,
4031    //   b) `limit` silently truncated, or
4032    //   c) the returned timestamps don't actually cover the expected range.
4033    // Echoing `updated_since` (what the server parsed, verbatim) plus
4034    // earliest / latest `updated_at` from the result set lets the
4035    // scenario pin the failure mode without changing any behavior. Fields
4036    // are additive — no existing caller assertion regresses.
4037    let earliest_updated_at = mems.first().map(|m| m.updated_at.clone());
4038    let latest_updated_at = mems.last().map(|m| m.updated_at.clone());
4039
4040    (
4041        StatusCode::OK,
4042        Json(json!({
4043            "count": mems.len(),
4044            "limit": limit,
4045            "updated_since": q.since,
4046            "earliest_updated_at": earliest_updated_at,
4047            "latest_updated_at": latest_updated_at,
4048            "memories": mems,
4049        })),
4050    )
4051        .into_response()
4052}
4053
4054// ---------------------------------------------------------------------------
4055// HTTP parity helpers.
4056// ---------------------------------------------------------------------------
4057
4058/// Fan out a locally-committed memory to peers via quorum store. On success,
4059/// returns `None`; on quorum miss, returns `Some(503_response)` for the
4060/// caller to short-circuit with. Network errors are logged and swallowed —
4061/// the local commit already landed and the sync-daemon catches stragglers.
4062async fn fanout_or_503(app: &AppState, mem: &Memory) -> Option<axum::response::Response> {
4063    let fed = app.federation.as_ref().as_ref()?;
4064    match crate::federation::broadcast_store_quorum(fed, mem).await {
4065        Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
4066            Ok(_) => None,
4067            Err(err) => {
4068                let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4069                Some(
4070                    (
4071                        StatusCode::SERVICE_UNAVAILABLE,
4072                        [("Retry-After", "2")],
4073                        Json(serde_json::to_value(&payload).unwrap_or_default()),
4074                    )
4075                        .into_response(),
4076                )
4077            }
4078        },
4079        Err(e) => {
4080            tracing::warn!("fanout error (local committed): {e:?}");
4081            None
4082        }
4083    }
4084}
4085
4086// ---------------------------------------------------------------------------
4087// HTTP parity for MCP-only tools (feat/http-parity-for-mcp-only-tools).
4088//
4089// Each endpoint below mirrors an existing handler in `mcp.rs`, adapting the
4090// MCP tool's params shape to the HTTP request surface used by the testbook v3
4091// scenarios. Where practical the HTTP wrapper delegates straight into
4092// `crate::mcp::handle_*` with a synthesized params Value so the business-logic
4093// contract stays single-sourced; where a scenario's assertion conflicts with
4094// the MCP contract (notably the S33 subscription shape and the S34/S35
4095// `/api/v1/namespaces` query-string routing), we match the scenario.
4096// ---------------------------------------------------------------------------
4097
4098/// Helper — resolve the caller's `agent_id` using the HTTP precedence chain,
4099/// accepting an optional body value, the `X-Agent-Id` header, and an optional
4100/// `?agent_id=` query param. Returns a 400 on invalid input; synthesizes an
4101/// anonymous id on miss.
4102fn resolve_caller_agent_id(
4103    body: Option<&str>,
4104    headers: &HeaderMap,
4105    query: Option<&str>,
4106) -> Result<String, String> {
4107    // Body → query → header (body wins, query next, header last). Matches the
4108    // precedence already used by `register_agent` / `create_memory` with
4109    // query inserted at the same tier as body for handlers that read from
4110    // the querystring (e.g. GET /inbox?agent_id=...).
4111    if let Some(id) = body
4112        && !id.is_empty()
4113    {
4114        validate::validate_agent_id(id).map_err(|e| format!("invalid agent_id: {e}"))?;
4115        return Ok(id.to_string());
4116    }
4117    if let Some(id) = query
4118        && !id.is_empty()
4119    {
4120        validate::validate_agent_id(id).map_err(|e| format!("invalid agent_id: {e}"))?;
4121        return Ok(id.to_string());
4122    }
4123    let header_val = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
4124    crate::identity::resolve_http_agent_id(None, header_val)
4125        .map_err(|e| format!("invalid agent_id: {e}"))
4126}
4127
4128// --- /api/v1/capabilities (GET) -------------------------------------------
4129
4130pub async fn get_capabilities(
4131    State(app): State<AppState>,
4132    headers: HeaderMap,
4133) -> impl IntoResponse {
4134    // Mirrors `mcp::handle_capabilities_with_conn`. Reranker state isn't
4135    // tracked on the HTTP AppState (HTTP daemons that wire a cross-encoder
4136    // record it via the tier config's `cross_encoder` flag, which is
4137    // enough for scenario S30's equivalence check).
4138    //
4139    // v0.6.2 (S18): forward the *runtime* embedder state so
4140    // `features.embedder_loaded` reports whether the HF model actually
4141    // materialized at serve startup (not just whether the tier config
4142    // asked for one). An offline CI runner can fail the model fetch and
4143    // end up with `semantic_search=true` (from config) but no embedder in
4144    // the AppState — setup scripts need this signal to refuse to start
4145    // scenarios that depend on semantic recall.
4146    //
4147    // v0.6.3 (capabilities schema v2): hold the DB lock briefly so the
4148    // dynamic blocks (active_rules, registered_count, pending_requests)
4149    // can be filled from live counts. Each query is a single COUNT(*) so
4150    // the lock window stays sub-millisecond.
4151    //
4152    // v0.6.3.1 (P1 honesty patch): honour the `Accept-Capabilities`
4153    // header. `v1` returns the legacy pre-v0.6.3.1 shape; anything else
4154    // (including absent) returns v2.
4155    let accept = headers
4156        .get("accept-capabilities")
4157        .and_then(|v| v.to_str().ok())
4158        .map_or(crate::mcp::CapabilitiesAccept::V2, |raw| {
4159            crate::mcp::CapabilitiesAccept::parse(raw)
4160        });
4161    let embedder_loaded = app.embedder.as_ref().is_some();
4162    let lock = app.db.lock().await;
4163    let conn = &lock.0;
4164    let result = crate::mcp::handle_capabilities_with_conn(
4165        app.tier_config.as_ref(),
4166        None,
4167        embedder_loaded,
4168        Some(conn),
4169        accept,
4170    );
4171    drop(lock);
4172    match result {
4173        Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4174        Err(e) => {
4175            tracing::error!("capabilities: {e}");
4176            (
4177                StatusCode::INTERNAL_SERVER_ERROR,
4178                Json(json!({"error": "internal server error"})),
4179            )
4180                .into_response()
4181        }
4182    }
4183}
4184
4185// --- /api/v1/notify (POST) + /api/v1/inbox (GET) ---------------------------
4186
4187#[derive(Deserialize)]
4188pub struct NotifyBody {
4189    pub target_agent_id: String,
4190    pub title: String,
4191    /// Accept either `payload` (MCP tool name) or `content` (S32 scenario).
4192    #[serde(default)]
4193    pub payload: Option<String>,
4194    #[serde(default)]
4195    pub content: Option<String>,
4196    #[serde(default)]
4197    pub priority: Option<i64>,
4198    #[serde(default)]
4199    pub tier: Option<String>,
4200    /// Optional explicit sender id — falls back to `X-Agent-Id` header.
4201    #[serde(default)]
4202    pub agent_id: Option<String>,
4203}
4204
4205pub async fn notify(
4206    State(app): State<AppState>,
4207    headers: HeaderMap,
4208    Json(body): Json<NotifyBody>,
4209) -> impl IntoResponse {
4210    let Some(payload) = body.payload.or(body.content) else {
4211        return (
4212            StatusCode::BAD_REQUEST,
4213            Json(json!({"error": "payload or content is required"})),
4214        )
4215            .into_response();
4216    };
4217    let sender = match resolve_caller_agent_id(body.agent_id.as_deref(), &headers, None) {
4218        Ok(id) => id,
4219        Err(e) => {
4220            return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4221        }
4222    };
4223
4224    let mut params = json!({
4225        "target_agent_id": body.target_agent_id,
4226        "title": body.title,
4227        "payload": payload,
4228    });
4229    if let Some(p) = body.priority {
4230        params["priority"] = json!(p);
4231    }
4232    if let Some(t) = body.tier {
4233        params["tier"] = json!(t);
4234    }
4235
4236    let lock = app.db.lock().await;
4237    let resolved_ttl = lock.2.clone();
4238    // Route via the MCP handler so the wire contract stays single-sourced.
4239    // `mcp_client = Some(&sender)` makes `resolve_agent_id(None, _)` return
4240    // the caller-resolved HTTP id — same effective provenance.
4241    let mcp_client = sender.clone();
4242    let result = crate::mcp::handle_notify(&lock.0, &params, &resolved_ttl, Some(&mcp_client));
4243
4244    // v0.6.2 (S32): capture the just-inserted notify row and fan it out to
4245    // peers. Without this, alice's notify on node-1 lands in bob's inbox on
4246    // node-1 only — when bob polls `/api/v1/inbox` against node-2 he sees
4247    // nothing. The HTTP wrapper bypassed the `create_memory` fanout path
4248    // that every other `db::insert` write uses, so we wire it here with the
4249    // same posture as `fanout_or_503`: on quorum miss return 503; on a
4250    // network error, swallow (local commit landed, sync-daemon catches up).
4251    let fanout_mem = match &result {
4252        Ok(v) => v
4253            .get("id")
4254            .and_then(|x| x.as_str())
4255            .and_then(|id| db::get(&lock.0, id).ok().flatten()),
4256        Err(_) => None,
4257    };
4258    drop(lock);
4259
4260    match result {
4261        Ok(v) => {
4262            if let Some(mem) = fanout_mem
4263                && let Some(resp) = fanout_or_503(&app, &mem).await
4264            {
4265                return resp;
4266            }
4267            (StatusCode::CREATED, Json(v)).into_response()
4268        }
4269        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4270    }
4271}
4272
4273#[derive(Deserialize)]
4274pub struct InboxQuery {
4275    #[serde(default)]
4276    pub agent_id: Option<String>,
4277    #[serde(default)]
4278    pub unread_only: Option<bool>,
4279    #[serde(default)]
4280    pub limit: Option<u64>,
4281}
4282
4283pub async fn get_inbox(
4284    State(app): State<AppState>,
4285    headers: HeaderMap,
4286    Query(q): Query<InboxQuery>,
4287) -> impl IntoResponse {
4288    let owner = match resolve_caller_agent_id(None, &headers, q.agent_id.as_deref()) {
4289        Ok(id) => id,
4290        Err(e) => {
4291            return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4292        }
4293    };
4294
4295    let mut params = json!({"agent_id": owner});
4296    if let Some(u) = q.unread_only {
4297        params["unread_only"] = json!(u);
4298    }
4299    if let Some(l) = q.limit {
4300        params["limit"] = json!(l);
4301    }
4302    let lock = app.db.lock().await;
4303    // Pass the resolved owner as `mcp_client` too so `handle_inbox`'s
4304    // identity-resolution fallback lands on the same id whichever branch
4305    // it consults (it prefers `params["agent_id"]` when present).
4306    let result = crate::mcp::handle_inbox(&lock.0, &params, None);
4307    drop(lock);
4308    match result {
4309        Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4310        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4311    }
4312}
4313
4314// --- /api/v1/subscriptions (POST / DELETE / GET) ---------------------------
4315//
4316// Two shapes are supported. The webhook shape from the MCP tool
4317// (`{url, events, secret, namespace_filter, agent_filter}`) is the primary
4318// contract. Scenario S33 uses a lighter shape (`{agent_id, namespace}`) to
4319// express "subscribe this agent to a namespace". We accept both: when a
4320// namespace is supplied without a URL we synthesize an internal loopback URL
4321// (`http://localhost/_ns/<agent_id>/<namespace>`) that passes SSRF validation
4322// and sets `agent_filter`/`namespace_filter` accordingly. This lets S33 round-
4323// trip without needing a separate subscriptions table.
4324
4325#[derive(Deserialize)]
4326pub struct SubscribeBody {
4327    /// Webhook URL — required for the MCP contract, optional for the S33
4328    /// namespace-subscription shape.
4329    #[serde(default)]
4330    pub url: Option<String>,
4331    #[serde(default)]
4332    pub events: Option<String>,
4333    #[serde(default)]
4334    pub secret: Option<String>,
4335    #[serde(default)]
4336    pub namespace_filter: Option<String>,
4337    #[serde(default)]
4338    pub agent_filter: Option<String>,
4339    /// S33 shape: caller-supplied namespace to track.
4340    #[serde(default)]
4341    pub namespace: Option<String>,
4342    /// Optional explicit subscriber id.
4343    #[serde(default)]
4344    pub agent_id: Option<String>,
4345}
4346
4347pub async fn subscribe(
4348    State(app): State<AppState>,
4349    headers: HeaderMap,
4350    Json(body): Json<SubscribeBody>,
4351) -> impl IntoResponse {
4352    let caller = match resolve_caller_agent_id(body.agent_id.as_deref(), &headers, None) {
4353        Ok(id) => id,
4354        Err(e) => {
4355            return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4356        }
4357    };
4358
4359    // Rewrite S33's `{agent_id, namespace}` body into the webhook shape.
4360    let (url, namespace_filter, agent_filter) = if let Some(u) = body.url {
4361        (u, body.namespace_filter, body.agent_filter)
4362    } else {
4363        let Some(ns) = body.namespace.clone() else {
4364            return (
4365                StatusCode::BAD_REQUEST,
4366                Json(json!({"error": "url or namespace is required"})),
4367            )
4368                .into_response();
4369        };
4370        // Synthetic loopback URL — passes the SSRF allowlist (localhost
4371        // loopback hostnames are permitted). The synthetic host encodes
4372        // (agent_id, namespace) so the GET view can round-trip them.
4373        let synthetic = format!("http://localhost/_ns/{caller}/{ns}");
4374        (
4375            synthetic,
4376            Some(ns),
4377            body.agent_filter.or_else(|| Some(caller.clone())),
4378        )
4379    };
4380
4381    let events = body.events.unwrap_or_else(|| "*".to_string());
4382
4383    // Ensure the caller is a registered agent (the MCP tool enforces this).
4384    // Auto-register for the S33 shape so scenario callers don't have to
4385    // pre-call /agents themselves — same auto-create pattern used elsewhere
4386    // for the HTTP surface.
4387    let lock = app.db.lock().await;
4388    let already = db::list_agents(&lock.0)
4389        .ok()
4390        .is_some_and(|a| a.iter().any(|x| x.agent_id == caller));
4391    if !already {
4392        let _ = db::register_agent(&lock.0, &caller, "ai:generic", &[]);
4393    }
4394    // Inline subscribe path — we cannot delegate to `mcp::handle_subscribe`
4395    // here because that helper re-resolves the caller via
4396    // `resolve_agent_id(None, Some(mcp_client))`, which synthesizes a
4397    // `ai:<client>@<host>:pid-N` id rather than using the HTTP-resolved
4398    // `caller` verbatim. An HTTP caller registered under "ai:bob" must be
4399    // able to subscribe as "ai:bob", not as "ai:ai:bob@host:pid-N".
4400    let sub_result: Result<serde_json::Value, String> = (|| {
4401        crate::subscriptions::validate_url(&url).map_err(|e| e.to_string())?;
4402        let id = crate::subscriptions::insert(
4403            &lock.0,
4404            &crate::subscriptions::NewSubscription {
4405                url: &url,
4406                events: &events,
4407                secret: body.secret.as_deref(),
4408                namespace_filter: namespace_filter.as_deref(),
4409                agent_filter: agent_filter.as_deref(),
4410                created_by: Some(&caller),
4411                event_types: None,
4412            },
4413        )
4414        .map_err(|e| e.to_string())?;
4415        Ok(json!({
4416            "id": id,
4417            "url": url,
4418            "events": events,
4419            "namespace_filter": namespace_filter,
4420            "agent_filter": agent_filter,
4421            "created_by": caller,
4422        }))
4423    })();
4424    // Federate the `_agents` write we may have just done so registration is
4425    // cluster-wide. (Best-effort — subscriptions themselves live in a
4426    // separate table that does not ride `sync_push` today.)
4427    let registered_mem = if already {
4428        None
4429    } else {
4430        db::list(
4431            &lock.0,
4432            Some("_agents"),
4433            None,
4434            1000,
4435            0,
4436            None,
4437            None,
4438            None,
4439            None,
4440            None,
4441        )
4442        .ok()
4443        .and_then(|rows| {
4444            rows.into_iter()
4445                .find(|m| m.title == format!("agent:{caller}"))
4446        })
4447    };
4448    drop(lock);
4449
4450    if let Some(ref mem) = registered_mem
4451        && let Some(resp) = fanout_or_503(&app, mem).await
4452    {
4453        return resp;
4454    }
4455
4456    match sub_result {
4457        Ok(mut v) => {
4458            // Echo the caller's view of the subscription so S33 can find
4459            // {namespace, agent_id} keys in the response without relying on
4460            // the synthetic URL.
4461            if let Some(obj) = v.as_object_mut() {
4462                if let Some(ref ns) = namespace_filter {
4463                    obj.insert("namespace".into(), json!(ns));
4464                }
4465                obj.insert("agent_id".into(), json!(caller));
4466            }
4467            (StatusCode::CREATED, Json(v)).into_response()
4468        }
4469        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4470    }
4471}
4472
4473#[derive(Deserialize)]
4474pub struct UnsubscribeQuery {
4475    #[serde(default)]
4476    pub id: Option<String>,
4477    /// S33 shape: (`agent_id`, namespace) lookup.
4478    #[serde(default)]
4479    pub agent_id: Option<String>,
4480    #[serde(default)]
4481    pub namespace: Option<String>,
4482}
4483
4484pub async fn unsubscribe(
4485    State(app): State<AppState>,
4486    headers: HeaderMap,
4487    Query(q): Query<UnsubscribeQuery>,
4488) -> impl IntoResponse {
4489    // Prefer explicit id. If absent, dispatch by (agent_id, namespace) for
4490    // S33 — find the first matching row from list() and delete it.
4491    if let Some(id) = q.id.clone() {
4492        let mut params = json!({"id": id});
4493        // Keep the key name stable across both handlers' interior shapes.
4494        let _ = params.as_object_mut();
4495        let lock = app.db.lock().await;
4496        let result = crate::mcp::handle_unsubscribe(&lock.0, &params);
4497        drop(lock);
4498        return match result {
4499            Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4500            Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4501        };
4502    }
4503
4504    let caller = match resolve_caller_agent_id(None, &headers, q.agent_id.as_deref()) {
4505        Ok(id) => id,
4506        Err(e) => {
4507            return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4508        }
4509    };
4510    let Some(ns) = q.namespace else {
4511        return (
4512            StatusCode::BAD_REQUEST,
4513            Json(json!({"error": "id or (agent_id, namespace) required"})),
4514        )
4515            .into_response();
4516    };
4517
4518    let lock = app.db.lock().await;
4519    let subs = crate::subscriptions::list(&lock.0).unwrap_or_default();
4520    let target = subs.into_iter().find(|s| {
4521        s.namespace_filter.as_deref() == Some(ns.as_str())
4522            && (s.agent_filter.as_deref() == Some(caller.as_str())
4523                || s.created_by.as_deref() == Some(caller.as_str()))
4524    });
4525    let outcome = match target {
4526        Some(s) => crate::subscriptions::delete(&lock.0, &s.id).map(|r| (s.id, r)),
4527        None => Ok((String::new(), false)),
4528    };
4529    drop(lock);
4530    match outcome {
4531        Ok((id, removed)) => {
4532            (StatusCode::OK, Json(json!({"id": id, "removed": removed}))).into_response()
4533        }
4534        Err(e) => {
4535            tracing::error!("unsubscribe: {e}");
4536            (
4537                StatusCode::INTERNAL_SERVER_ERROR,
4538                Json(json!({"error": "internal server error"})),
4539            )
4540                .into_response()
4541        }
4542    }
4543}
4544
4545#[derive(Deserialize)]
4546pub struct ListSubscriptionsQuery {
4547    #[serde(default)]
4548    pub agent_id: Option<String>,
4549}
4550
4551pub async fn list_subscriptions(
4552    State(state): State<Db>,
4553    Query(q): Query<ListSubscriptionsQuery>,
4554) -> impl IntoResponse {
4555    let lock = state.lock().await;
4556    let subs = match crate::subscriptions::list(&lock.0) {
4557        Ok(s) => s,
4558        Err(e) => {
4559            tracing::error!("list_subscriptions: {e}");
4560            return (
4561                StatusCode::INTERNAL_SERVER_ERROR,
4562                Json(json!({"error": "internal server error"})),
4563            )
4564                .into_response();
4565        }
4566    };
4567    drop(lock);
4568    // Filter by agent_id when the caller passed one (S33's per-agent view).
4569    let filtered: Vec<_> = match q.agent_id.as_deref() {
4570        Some(aid) => subs
4571            .into_iter()
4572            .filter(|s| {
4573                s.agent_filter.as_deref() == Some(aid) || s.created_by.as_deref() == Some(aid)
4574            })
4575            .collect(),
4576        None => subs,
4577    };
4578    // Expose the subscribed namespace as a top-level field per row so S33 can
4579    // read `namespace` directly without probing `namespace_filter`.
4580    let rows: Vec<serde_json::Value> = filtered
4581        .iter()
4582        .map(|s| {
4583            json!({
4584                "id": s.id,
4585                "url": s.url,
4586                "events": s.events,
4587                "namespace": s.namespace_filter,
4588                "namespace_filter": s.namespace_filter,
4589                "agent_filter": s.agent_filter,
4590                "agent_id": s.agent_filter.clone().or(s.created_by.clone()),
4591                "created_by": s.created_by,
4592                "created_at": s.created_at,
4593                "dispatch_count": s.dispatch_count,
4594                "failure_count": s.failure_count,
4595            })
4596        })
4597        .collect();
4598    let count = rows.len();
4599    (
4600        StatusCode::OK,
4601        Json(json!({"count": count, "subscriptions": rows})),
4602    )
4603        .into_response()
4604}
4605
4606// --- /api/v1/namespaces/{ns}/standard (POST / GET / DELETE) ----------------
4607//    +/api/v1/namespaces (POST with body.namespace, GET/DELETE with ?namespace=)
4608//
4609// S34/S35 drive the standard via the bare `/api/v1/namespaces` surface; the
4610// `/namespaces/{ns}/standard` path is kept for API-shape parity with the MCP
4611// tool namespace. Both share a single underlying implementation.
4612
4613#[derive(Deserialize)]
4614pub struct NamespaceStandardBody {
4615    /// The memory id representing the standard.
4616    #[serde(default)]
4617    pub id: Option<String>,
4618    /// Optional parent namespace for chain lookups.
4619    #[serde(default)]
4620    pub parent: Option<String>,
4621    /// Optional governance policy to merge into the standard's metadata.
4622    #[serde(default)]
4623    pub governance: Option<serde_json::Value>,
4624    /// Accepted for the path-less `/namespaces` form — ignored when the
4625    /// namespace is supplied via a URL segment.
4626    #[serde(default)]
4627    pub namespace: Option<String>,
4628    /// Some scenarios nest the payload under `standard` (S34 does so).
4629    #[serde(default)]
4630    pub standard: Option<Box<NamespaceStandardBody>>,
4631}
4632
4633fn flatten_standard_body(body: NamespaceStandardBody) -> NamespaceStandardBody {
4634    // When the caller nests fields under `standard: { … }` (S34 shape), pull
4635    // the inner payload up to the top level so the single code path below
4636    // can read it uniformly.
4637    if let Some(inner) = body.standard {
4638        let mut merged = *inner;
4639        if merged.namespace.is_none() {
4640            merged.namespace = body.namespace;
4641        }
4642        if merged.id.is_none() {
4643            merged.id = body.id;
4644        }
4645        if merged.parent.is_none() {
4646            merged.parent = body.parent;
4647        }
4648        if merged.governance.is_none() {
4649            merged.governance = body.governance;
4650        }
4651        merged
4652    } else {
4653        body
4654    }
4655}
4656
4657fn namespace_standard_params(ns: &str, body: &NamespaceStandardBody) -> serde_json::Value {
4658    let mut params = json!({"namespace": ns});
4659    if let Some(ref id) = body.id {
4660        params["id"] = json!(id);
4661    }
4662    if let Some(ref p) = body.parent {
4663        params["parent"] = json!(p);
4664    }
4665    if let Some(ref g) = body.governance {
4666        params["governance"] = g.clone();
4667    }
4668    params
4669}
4670
4671async fn set_namespace_standard_inner(
4672    app: &AppState,
4673    ns: &str,
4674    body: NamespaceStandardBody,
4675) -> axum::response::Response {
4676    let body = flatten_standard_body(body);
4677    // Auto-seed a placeholder standard memory when the caller didn't supply
4678    // an `id`. S34's body is `{governance: …}` with no id — we create a
4679    // minimal standard memory so the governance policy has a home.
4680    let lock = app.db.lock().await;
4681    let resolved_id = if let Some(id) = body.id.clone() {
4682        id
4683    } else {
4684        // Look for an existing placeholder first to keep repeat calls
4685        // idempotent; otherwise insert a new row.
4686        let existing = db::list(
4687            &lock.0,
4688            Some(ns),
4689            None,
4690            1,
4691            0,
4692            None,
4693            None,
4694            None,
4695            Some("_namespace_standard"),
4696            None,
4697        )
4698        .ok()
4699        .and_then(|v| v.into_iter().next());
4700        if let Some(m) = existing {
4701            m.id
4702        } else {
4703            let now = Utc::now().to_rfc3339();
4704            let placeholder = Memory {
4705                id: Uuid::new_v4().to_string(),
4706                tier: Tier::Long,
4707                namespace: ns.to_string(),
4708                title: format!("_standard:{ns}"),
4709                content: format!("namespace standard for {ns}"),
4710                tags: vec!["_namespace_standard".to_string()],
4711                priority: 5,
4712                confidence: 1.0,
4713                source: "api".into(),
4714                access_count: 0,
4715                created_at: now.clone(),
4716                updated_at: now,
4717                last_accessed_at: None,
4718                expires_at: None,
4719                metadata: serde_json::json!({"agent_id": "system"}),
4720            };
4721            match db::insert(&lock.0, &placeholder) {
4722                Ok(id) => id,
4723                Err(e) => {
4724                    tracing::error!("namespace_standard: placeholder insert failed: {e}");
4725                    return (
4726                        StatusCode::INTERNAL_SERVER_ERROR,
4727                        Json(json!({"error": "internal server error"})),
4728                    )
4729                        .into_response();
4730                }
4731            }
4732        }
4733    };
4734    let mut effective = body;
4735    effective.id = Some(resolved_id.clone());
4736    let params = namespace_standard_params(ns, &effective);
4737    let result = crate::mcp::handle_namespace_set_standard(&lock.0, &params);
4738    // Capture the standard memory so we can fan it out to peers — cluster
4739    // visibility of governance rules matters for S34/S35.
4740    let standard_mem = db::get(&lock.0, &resolved_id).ok().flatten();
4741    // v0.6.2 (S35): also capture the freshly-written namespace_meta row
4742    // so peers learn the explicit (namespace, standard_id, parent) tuple.
4743    // Without this, peers auto-detect a parent via `-` prefix which may
4744    // disagree with what the originator set.
4745    let meta_entry = db::get_namespace_meta_entry(&lock.0, ns).ok().flatten();
4746    drop(lock);
4747
4748    match result {
4749        Ok(v) => {
4750            if let Some(ref mem) = standard_mem
4751                && let Some(resp) = fanout_or_503(app, mem).await
4752            {
4753                return resp;
4754            }
4755            if let (Some(entry), Some(fed)) = (meta_entry.as_ref(), app.federation.as_ref()) {
4756                match crate::federation::broadcast_namespace_meta_quorum(fed, entry).await {
4757                    Ok(tracker) => {
4758                        if let Err(err) = crate::federation::finalise_quorum(&tracker) {
4759                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4760                            return (
4761                                StatusCode::SERVICE_UNAVAILABLE,
4762                                [("Retry-After", "2")],
4763                                Json(serde_json::to_value(&payload).unwrap_or_default()),
4764                            )
4765                                .into_response();
4766                        }
4767                    }
4768                    Err(err) => {
4769                        let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4770                        return (
4771                            StatusCode::SERVICE_UNAVAILABLE,
4772                            [("Retry-After", "2")],
4773                            Json(serde_json::to_value(&payload).unwrap_or_default()),
4774                        )
4775                            .into_response();
4776                    }
4777                }
4778            }
4779            (StatusCode::CREATED, Json(v)).into_response()
4780        }
4781        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4782    }
4783}
4784
4785pub async fn set_namespace_standard(
4786    State(app): State<AppState>,
4787    Path(ns): Path<String>,
4788    Json(body): Json<NamespaceStandardBody>,
4789) -> impl IntoResponse {
4790    set_namespace_standard_inner(&app, &ns, body).await
4791}
4792
4793#[derive(Deserialize)]
4794pub struct NamespaceStandardQuery {
4795    #[serde(default)]
4796    pub namespace: Option<String>,
4797    #[serde(default)]
4798    pub inherit: Option<bool>,
4799}
4800
4801pub async fn get_namespace_standard(
4802    State(state): State<Db>,
4803    Path(ns): Path<String>,
4804    Query(q): Query<NamespaceStandardQuery>,
4805) -> impl IntoResponse {
4806    let mut params = json!({"namespace": ns});
4807    if let Some(inh) = q.inherit {
4808        params["inherit"] = json!(inh);
4809    }
4810    let lock = state.lock().await;
4811    let result = crate::mcp::handle_namespace_get_standard(&lock.0, &params);
4812    drop(lock);
4813    match result {
4814        Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4815        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4816    }
4817}
4818
4819pub async fn clear_namespace_standard(
4820    State(app): State<AppState>,
4821    Path(ns): Path<String>,
4822) -> impl IntoResponse {
4823    clear_namespace_standard_inner(&app, &ns).await
4824}
4825
4826// Query-string forms for the S34/S35 `/api/v1/namespaces?namespace=…` shape.
4827pub async fn set_namespace_standard_qs(
4828    State(app): State<AppState>,
4829    Json(body): Json<NamespaceStandardBody>,
4830) -> impl IntoResponse {
4831    let Some(ns) = body
4832        .namespace
4833        .clone()
4834        .or_else(|| body.standard.as_ref().and_then(|s| s.namespace.clone()))
4835    else {
4836        return (
4837            StatusCode::BAD_REQUEST,
4838            Json(json!({"error": "namespace is required"})),
4839        )
4840            .into_response();
4841    };
4842    set_namespace_standard_inner(&app, &ns, body).await
4843}
4844
4845pub async fn get_namespace_standard_qs(
4846    State(state): State<Db>,
4847    Query(q): Query<NamespaceStandardQuery>,
4848) -> impl IntoResponse {
4849    // If no namespace is supplied this shares a route with the existing
4850    // `list_namespaces` GET; the router chains the two so a plain
4851    // `GET /api/v1/namespaces` still returns the list.
4852    let Some(ns) = q.namespace.clone() else {
4853        return list_namespaces(State(state)).await.into_response();
4854    };
4855    let mut params = json!({"namespace": ns});
4856    if let Some(inh) = q.inherit {
4857        params["inherit"] = json!(inh);
4858    }
4859    let lock = state.lock().await;
4860    let result = crate::mcp::handle_namespace_get_standard(&lock.0, &params);
4861    drop(lock);
4862    match result {
4863        Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4864        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4865    }
4866}
4867
4868pub async fn clear_namespace_standard_qs(
4869    State(app): State<AppState>,
4870    Query(q): Query<NamespaceStandardQuery>,
4871) -> impl IntoResponse {
4872    let Some(ns) = q.namespace else {
4873        return (
4874            StatusCode::BAD_REQUEST,
4875            Json(json!({"error": "namespace is required"})),
4876        )
4877            .into_response();
4878    };
4879    clear_namespace_standard_inner(&app, &ns).await
4880}
4881
4882/// v0.6.2 (S35 follow-up): shared implementation for path and query-string
4883/// clear handlers. Runs the local clear then, on success, fans the cleared
4884/// namespace out to peers via `broadcast_namespace_meta_clear_quorum`.
4885/// Returns 503 `quorum_not_met` when federation is configured and the quorum
4886/// contract fails — matching the pattern established by
4887/// `set_namespace_standard_inner`.
4888async fn clear_namespace_standard_inner(app: &AppState, ns: &str) -> axum::response::Response {
4889    let params = json!({"namespace": ns});
4890    let lock = app.db.lock().await;
4891    let result = crate::mcp::handle_namespace_clear_standard(&lock.0, &params);
4892    drop(lock);
4893    match result {
4894        Ok(v) => {
4895            if let Some(fed) = app.federation.as_ref() {
4896                let namespaces = vec![ns.to_string()];
4897                match crate::federation::broadcast_namespace_meta_clear_quorum(fed, &namespaces)
4898                    .await
4899                {
4900                    Ok(tracker) => {
4901                        if let Err(err) = crate::federation::finalise_quorum(&tracker) {
4902                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4903                            return (
4904                                StatusCode::SERVICE_UNAVAILABLE,
4905                                [("Retry-After", "2")],
4906                                Json(serde_json::to_value(&payload).unwrap_or_default()),
4907                            )
4908                                .into_response();
4909                        }
4910                    }
4911                    Err(err) => {
4912                        let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4913                        return (
4914                            StatusCode::SERVICE_UNAVAILABLE,
4915                            [("Retry-After", "2")],
4916                            Json(serde_json::to_value(&payload).unwrap_or_default()),
4917                        )
4918                            .into_response();
4919                    }
4920                }
4921            }
4922            (StatusCode::OK, Json(v)).into_response()
4923        }
4924        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4925    }
4926}
4927
4928// --- /api/v1/session/start (POST) ------------------------------------------
4929
4930#[derive(Deserialize)]
4931pub struct SessionStartBody {
4932    #[serde(default)]
4933    pub namespace: Option<String>,
4934    #[serde(default)]
4935    pub limit: Option<u64>,
4936    #[serde(default)]
4937    pub agent_id: Option<String>,
4938}
4939
4940pub async fn session_start(
4941    State(state): State<Db>,
4942    headers: HeaderMap,
4943    Json(body): Json<SessionStartBody>,
4944) -> impl IntoResponse {
4945    // agent_id is optional for session_start; but if supplied it must validate.
4946    if let Some(ref id) = body.agent_id
4947        && let Err(e) = validate::validate_agent_id(id)
4948    {
4949        return (
4950            StatusCode::BAD_REQUEST,
4951            Json(json!({"error": format!("invalid agent_id: {e}")})),
4952        )
4953            .into_response();
4954    }
4955    let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
4956    let _ = header_agent_id; // identity currently informational for session_start
4957    let mut params = json!({});
4958    if let Some(ref n) = body.namespace {
4959        params["namespace"] = json!(n);
4960    }
4961    if let Some(l) = body.limit {
4962        params["limit"] = json!(l);
4963    }
4964    let lock = state.lock().await;
4965    let result = crate::mcp::handle_session_start(&lock.0, &params, None);
4966    drop(lock);
4967    match result {
4968        Ok(mut v) => {
4969            // Stamp a stable session id so callers (S36) can correlate
4970            // subsequent writes. We don't persist sessions today; the id is
4971            // advisory and round-tripped via metadata by the caller.
4972            if let Some(obj) = v.as_object_mut() {
4973                obj.entry("session_id")
4974                    .or_insert_with(|| json!(Uuid::new_v4().to_string()));
4975                if let Some(ref a) = body.agent_id {
4976                    obj.insert("agent_id".into(), json!(a));
4977                }
4978            }
4979            (StatusCode::OK, Json(v)).into_response()
4980        }
4981        Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4982    }
4983}
4984
4985#[cfg(test)]
4986mod tests {
4987    use super::*;
4988
4989    fn test_state() -> Db {
4990        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4991        let path = std::path::PathBuf::from(":memory:");
4992        Arc::new(Mutex::new((conn, path, ResolvedTtl::default(), true)))
4993    }
4994
4995    #[tokio::test]
4996    async fn health_returns_ok() {
4997        let state = test_state();
4998        let lock = state.lock().await;
4999        let ok = db::health_check(&lock.0).unwrap_or(false);
5000        assert!(ok);
5001    }
5002
5003    #[tokio::test]
5004    async fn store_and_retrieve_via_state() {
5005        let state = test_state();
5006        let lock = state.lock().await;
5007        let now = Utc::now();
5008        let mem = Memory {
5009            id: Uuid::new_v4().to_string(),
5010            tier: Tier::Long,
5011            namespace: "test".into(),
5012            title: "Handler test".into(),
5013            content: "Testing handlers.".into(),
5014            tags: vec!["test".into()],
5015            priority: 7,
5016            confidence: 1.0,
5017            source: "test".into(),
5018            access_count: 0,
5019            created_at: now.to_rfc3339(),
5020            updated_at: now.to_rfc3339(),
5021            last_accessed_at: None,
5022            expires_at: None,
5023            metadata: serde_json::json!({}),
5024        };
5025        let id = db::insert(&lock.0, &mem).unwrap();
5026        let got = db::get(&lock.0, &id).unwrap().unwrap();
5027        assert_eq!(got.title, "Handler test");
5028    }
5029
5030    #[tokio::test]
5031    async fn recall_via_state() {
5032        let state = test_state();
5033        let lock = state.lock().await;
5034        let now = Utc::now();
5035        let mem = Memory {
5036            id: Uuid::new_v4().to_string(),
5037            tier: Tier::Long,
5038            namespace: "test".into(),
5039            title: "Recall handler test".into(),
5040            content: "Content for recall.".into(),
5041            tags: vec![],
5042            priority: 8,
5043            confidence: 1.0,
5044            source: "test".into(),
5045            access_count: 0,
5046            created_at: now.to_rfc3339(),
5047            updated_at: now.to_rfc3339(),
5048            last_accessed_at: None,
5049            expires_at: None,
5050            metadata: serde_json::json!({}),
5051        };
5052        db::insert(&lock.0, &mem).unwrap();
5053        let (results, _outcome) = db::recall(
5054            &lock.0,
5055            "recall handler",
5056            Some("test"),
5057            10,
5058            None,
5059            None,
5060            None,
5061            crate::models::SHORT_TTL_EXTEND_SECS,
5062            crate::models::MID_TTL_EXTEND_SECS,
5063            None,
5064            None,
5065        )
5066        .unwrap();
5067        assert!(!results.is_empty());
5068        assert!(results[0].1 > 0.0); // has score
5069    }
5070
5071    #[tokio::test]
5072    async fn stats_via_state() {
5073        let state = test_state();
5074        let lock = state.lock().await;
5075        let path = std::path::Path::new(":memory:");
5076        let s = db::stats(&lock.0, path).unwrap();
5077        assert_eq!(s.total, 0);
5078    }
5079
5080    #[tokio::test]
5081    async fn bulk_size_limit() {
5082        assert_eq!(MAX_BULK_SIZE, 1000);
5083    }
5084
5085    #[tokio::test]
5086    async fn list_empty_namespace() {
5087        let state = test_state();
5088        let lock = state.lock().await;
5089        let results = db::list(
5090            &lock.0,
5091            Some("nonexistent"),
5092            None,
5093            10,
5094            0,
5095            None,
5096            None,
5097            None,
5098            None,
5099            None,
5100        )
5101        .unwrap();
5102        assert!(results.is_empty());
5103    }
5104
5105    #[tokio::test]
5106    async fn create_and_update_with_metadata() {
5107        let state = test_state();
5108        let lock = state.lock().await;
5109        let now = Utc::now();
5110
5111        // Create with metadata
5112        let mem = Memory {
5113            id: Uuid::new_v4().to_string(),
5114            tier: Tier::Long,
5115            namespace: "test".into(),
5116            title: "HTTP metadata test".into(),
5117            content: "Testing metadata through handler layer.".into(),
5118            tags: vec![],
5119            priority: 5,
5120            confidence: 1.0,
5121            source: "api".into(),
5122            access_count: 0,
5123            created_at: now.to_rfc3339(),
5124            updated_at: now.to_rfc3339(),
5125            last_accessed_at: None,
5126            expires_at: None,
5127            metadata: serde_json::json!({"http_test": true, "version": 1}),
5128        };
5129        let id = db::insert(&lock.0, &mem).unwrap();
5130
5131        // Verify metadata persisted
5132        let got = db::get(&lock.0, &id).unwrap().unwrap();
5133        assert_eq!(got.metadata["http_test"], true);
5134        assert_eq!(got.metadata["version"], 1);
5135
5136        // Update metadata via db::update (same path as update_memory handler)
5137        let new_meta =
5138            serde_json::json!({"http_test": true, "version": 2, "updated_by": "handler"});
5139        let (found, _) = db::update(
5140            &lock.0,
5141            &id,
5142            None,
5143            None,
5144            None,
5145            None,
5146            None,
5147            None,
5148            None,
5149            None,
5150            Some(&new_meta),
5151        )
5152        .unwrap();
5153        assert!(found);
5154
5155        // Verify updated metadata
5156        let got = db::get(&lock.0, &id).unwrap().unwrap();
5157        assert_eq!(got.metadata["version"], 2);
5158        assert_eq!(got.metadata["updated_by"], "handler");
5159    }
5160
5161    // --- AppState wiring tests (issue #219) ---
5162
5163    use axum::{Router, body::Body, routing::get as axum_get, routing::post as axum_post};
5164    use tower::ServiceExt as _;
5165
5166    fn test_app_state(db: Db) -> AppState {
5167        AppState {
5168            db,
5169            embedder: Arc::new(None),
5170            vector_index: Arc::new(Mutex::new(None)),
5171            federation: Arc::new(None),
5172            tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
5173            scoring: Arc::new(crate::config::ResolvedScoring::default()),
5174        }
5175    }
5176
5177    #[tokio::test]
5178    async fn http_create_memory_uses_appstate_and_persists() {
5179        // Issue #219 regression — HTTP write path must reach `create_memory`
5180        // via `State<AppState>` and return 201 CREATED. Previously the daemon
5181        // held only `Db` and had no path to the embedder/vector index.
5182        let state = test_state();
5183        let app = Router::new()
5184            .route("/api/v1/memories", axum_post(create_memory))
5185            .with_state(test_app_state(state.clone()));
5186
5187        let body = serde_json::json!({
5188            "tier": "long",
5189            "namespace": "http-embed-test",
5190            "title": "Semantic-ready via HTTP",
5191            "content": "HTTP-authored memories must now participate in semantic recall.",
5192            "tags": ["issue-219"],
5193            "priority": 7,
5194            "confidence": 1.0,
5195            "source": "api",
5196            "metadata": {}
5197        });
5198        let resp = app
5199            .oneshot(
5200                axum::http::Request::builder()
5201                    .uri("/api/v1/memories")
5202                    .method("POST")
5203                    .header("content-type", "application/json")
5204                    .header("x-agent-id", "alice")
5205                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5206                    .unwrap(),
5207            )
5208            .await
5209            .unwrap();
5210        assert_eq!(resp.status(), StatusCode::CREATED);
5211
5212        // And the row is present in the DB.
5213        let lock = state.lock().await;
5214        let rows = db::list(
5215            &lock.0,
5216            Some("http-embed-test"),
5217            None,
5218            10,
5219            0,
5220            None,
5221            None,
5222            None,
5223            None,
5224            None,
5225        )
5226        .unwrap();
5227        assert!(!rows.is_empty(), "HTTP-authored memory must be persisted");
5228        assert_eq!(rows[0].title, "Semantic-ready via HTTP");
5229    }
5230
5231    #[tokio::test]
5232    async fn http_update_memory_uses_appstate() {
5233        // Issue #219 — update path must also route via `AppState` so the
5234        // embedder and vector index are reachable for content-change refresh.
5235        let state = test_state();
5236        let now = Utc::now();
5237        let id = {
5238            let lock = state.lock().await;
5239            let mem = Memory {
5240                id: Uuid::new_v4().to_string(),
5241                tier: Tier::Long,
5242                namespace: "http-embed-test".into(),
5243                title: "Before update".into(),
5244                content: "Original content.".into(),
5245                tags: vec![],
5246                priority: 5,
5247                confidence: 1.0,
5248                source: "test".into(),
5249                access_count: 0,
5250                created_at: now.to_rfc3339(),
5251                updated_at: now.to_rfc3339(),
5252                last_accessed_at: None,
5253                expires_at: None,
5254                metadata: serde_json::json!({}),
5255            };
5256            db::insert(&lock.0, &mem).unwrap()
5257        };
5258
5259        let app = Router::new()
5260            .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
5261            .with_state(test_app_state(state.clone()));
5262
5263        let patch = serde_json::json!({"content": "Updated content for semantic refresh."});
5264        let resp = app
5265            .oneshot(
5266                axum::http::Request::builder()
5267                    .uri(format!("/api/v1/memories/{id}"))
5268                    .method("PUT")
5269                    .header("content-type", "application/json")
5270                    .body(Body::from(serde_json::to_vec(&patch).unwrap()))
5271                    .unwrap(),
5272            )
5273            .await
5274            .unwrap();
5275        assert_eq!(resp.status(), StatusCode::OK);
5276    }
5277
5278    // --- Phase 3 foundation HTTP sync tests (issue #224) ---
5279
5280    #[tokio::test]
5281    async fn http_sync_push_applies_and_advances_clock() {
5282        // Smoke test for POST /api/v1/sync/push — memories land in the
5283        // receiver's DB and the vector clock records the sender's latest
5284        // `updated_at`. Full CRDT semantics are the v0.8.0 follow-up.
5285        let state = test_state();
5286        let app = Router::new()
5287            .route("/api/v1/sync/push", axum_post(sync_push))
5288            .with_state(test_app_state(state.clone()));
5289
5290        let now = Utc::now().to_rfc3339();
5291        let body = serde_json::json!({
5292            "sender_agent_id": "peer-alice",
5293            "sender_clock": {"entries": {}},
5294            "memories": [{
5295                "id": Uuid::new_v4().to_string(),
5296                "tier": "long",
5297                "namespace": "sync-smoke",
5298                "title": "From peer",
5299                "content": "Pushed via HTTP sync endpoint.",
5300                "tags": [],
5301                "priority": 5,
5302                "confidence": 1.0,
5303                "source": "api",
5304                "access_count": 0,
5305                "created_at": now,
5306                "updated_at": now,
5307                "last_accessed_at": null,
5308                "expires_at": null,
5309                "metadata": {"agent_id": "peer-alice"}
5310            }],
5311            "dry_run": false
5312        });
5313        let resp = app
5314            .oneshot(
5315                axum::http::Request::builder()
5316                    .uri("/api/v1/sync/push")
5317                    .method("POST")
5318                    .header("content-type", "application/json")
5319                    .header("x-agent-id", "local-receiver")
5320                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5321                    .unwrap(),
5322            )
5323            .await
5324            .unwrap();
5325        assert_eq!(resp.status(), StatusCode::OK);
5326
5327        // Row landed.
5328        let lock = state.lock().await;
5329        let rows = db::list(
5330            &lock.0,
5331            Some("sync-smoke"),
5332            None,
5333            10,
5334            0,
5335            None,
5336            None,
5337            None,
5338            None,
5339            None,
5340        )
5341        .unwrap();
5342        assert_eq!(rows.len(), 1);
5343        // Clock advanced — peer-alice registered against local-receiver.
5344        let clock = db::sync_state_load(&lock.0, "local-receiver").unwrap();
5345        assert!(
5346            clock.latest_from("peer-alice").is_some(),
5347            "push must record sender in sync_state; got: {:?}",
5348            clock.entries
5349        );
5350    }
5351
5352    #[tokio::test]
5353    async fn http_sync_push_applies_archives() {
5354        // S29 — sync_push must accept an `archives` field and move matching
5355        // rows from `memories` to `archived_memories` via
5356        // `db::archive_memory`. Missing ids no-op. The response exposes a
5357        // new `archived` counter.
5358        let state = test_state();
5359        // Seed one row that the peer will ask us to archive; one id that
5360        // doesn't exist here (must no-op, not error).
5361        let id = {
5362            let lock = state.lock().await;
5363            let now = Utc::now().to_rfc3339();
5364            let mem = Memory {
5365                id: Uuid::new_v4().to_string(),
5366                tier: Tier::Long,
5367                namespace: "s29".into(),
5368                title: "Archive M1".into(),
5369                content: "body".into(),
5370                tags: vec![],
5371                priority: 5,
5372                confidence: 1.0,
5373                source: "api".into(),
5374                access_count: 0,
5375                created_at: now.clone(),
5376                updated_at: now,
5377                last_accessed_at: None,
5378                expires_at: None,
5379                metadata: serde_json::json!({}),
5380            };
5381            db::insert(&lock.0, &mem).unwrap()
5382        };
5383
5384        let app = Router::new()
5385            .route("/api/v1/sync/push", axum_post(sync_push))
5386            .with_state(test_app_state(state.clone()));
5387
5388        let body = serde_json::json!({
5389            "sender_agent_id": "peer-a",
5390            "sender_clock": {"entries": {}},
5391            "memories": [],
5392            "archives": [id, "missing-on-peer"],
5393            "dry_run": false
5394        });
5395        let resp = app
5396            .oneshot(
5397                axum::http::Request::builder()
5398                    .uri("/api/v1/sync/push")
5399                    .method("POST")
5400                    .header("content-type", "application/json")
5401                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5402                    .unwrap(),
5403            )
5404            .await
5405            .unwrap();
5406        assert_eq!(resp.status(), StatusCode::OK);
5407        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5408            .await
5409            .unwrap();
5410        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5411        assert_eq!(v["archived"], 1, "live row must be archived");
5412        assert_eq!(v["noop"], 1, "missing id must no-op");
5413
5414        // Row is gone from active memories, present in archive, with the
5415        // correct `sync_push` reason.
5416        let lock = state.lock().await;
5417        assert!(db::get(&lock.0, &id).unwrap().is_none());
5418        let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5419        assert_eq!(archived.len(), 1);
5420        assert_eq!(archived[0]["id"], id);
5421        assert_eq!(archived[0]["archive_reason"], "sync_push");
5422    }
5423
5424    #[tokio::test]
5425    async fn http_archive_by_ids_happy_path() {
5426        // S29 — POST /api/v1/archive with `{ids:[...]}` soft-moves each
5427        // live row to the archive table with the supplied reason.
5428        // Missing ids are reported in a `missing` array, not an error.
5429        let state = test_state();
5430        let live_id = {
5431            let lock = state.lock().await;
5432            let now = Utc::now().to_rfc3339();
5433            let mem = Memory {
5434                id: Uuid::new_v4().to_string(),
5435                tier: Tier::Long,
5436                namespace: "s29".into(),
5437                title: "Live for archive".into(),
5438                content: "will be archived".into(),
5439                tags: vec![],
5440                priority: 5,
5441                confidence: 1.0,
5442                source: "api".into(),
5443                access_count: 0,
5444                created_at: now.clone(),
5445                updated_at: now,
5446                last_accessed_at: None,
5447                expires_at: None,
5448                metadata: serde_json::json!({}),
5449            };
5450            db::insert(&lock.0, &mem).unwrap()
5451        };
5452
5453        let app = Router::new()
5454            .route("/api/v1/archive", axum_post(archive_by_ids))
5455            .with_state(test_app_state(state.clone()));
5456
5457        let body = serde_json::json!({
5458            "ids": [live_id, "does-not-exist"],
5459            "reason": "scenario_s29"
5460        });
5461        let resp = app
5462            .oneshot(
5463                axum::http::Request::builder()
5464                    .uri("/api/v1/archive")
5465                    .method("POST")
5466                    .header("content-type", "application/json")
5467                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5468                    .unwrap(),
5469            )
5470            .await
5471            .unwrap();
5472        assert_eq!(resp.status(), StatusCode::OK);
5473        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5474            .await
5475            .unwrap();
5476        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5477        assert_eq!(v["count"], 1);
5478        assert_eq!(v["archived"].as_array().unwrap().len(), 1);
5479        assert_eq!(v["missing"].as_array().unwrap().len(), 1);
5480        assert_eq!(v["reason"], "scenario_s29");
5481
5482        // Row is gone from active, present in archive with caller's reason.
5483        let lock = state.lock().await;
5484        assert!(db::get(&lock.0, &live_id).unwrap().is_none());
5485        let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5486        assert_eq!(archived.len(), 1);
5487        assert_eq!(archived[0]["id"], live_id);
5488        assert_eq!(archived[0]["archive_reason"], "scenario_s29");
5489    }
5490
5491    #[tokio::test]
5492    async fn http_archive_by_ids_default_reason() {
5493        // When `reason` is omitted the response + archive row must record
5494        // the default "archive" reason (matches `db::archive_memory`).
5495        let state = test_state();
5496        let live_id = {
5497            let lock = state.lock().await;
5498            let now = Utc::now().to_rfc3339();
5499            let mem = Memory {
5500                id: Uuid::new_v4().to_string(),
5501                tier: Tier::Long,
5502                namespace: "s29-default".into(),
5503                title: "Default reason".into(),
5504                content: "c".into(),
5505                tags: vec![],
5506                priority: 5,
5507                confidence: 1.0,
5508                source: "api".into(),
5509                access_count: 0,
5510                created_at: now.clone(),
5511                updated_at: now,
5512                last_accessed_at: None,
5513                expires_at: None,
5514                metadata: serde_json::json!({}),
5515            };
5516            db::insert(&lock.0, &mem).unwrap()
5517        };
5518
5519        let app = Router::new()
5520            .route("/api/v1/archive", axum_post(archive_by_ids))
5521            .with_state(test_app_state(state.clone()));
5522        let body = serde_json::json!({"ids": [live_id]});
5523        let resp = app
5524            .oneshot(
5525                axum::http::Request::builder()
5526                    .uri("/api/v1/archive")
5527                    .method("POST")
5528                    .header("content-type", "application/json")
5529                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5530                    .unwrap(),
5531            )
5532            .await
5533            .unwrap();
5534        assert_eq!(resp.status(), StatusCode::OK);
5535        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5536            .await
5537            .unwrap();
5538        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5539        assert_eq!(v["reason"], "archive");
5540        let lock = state.lock().await;
5541        let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5542        assert_eq!(archived[0]["archive_reason"], "archive");
5543    }
5544
5545    #[tokio::test]
5546    async fn http_bulk_create_uses_appstate_and_persists() {
5547        // S40 prep — bulk_create previously took `State<Db>` with no path
5548        // to `app.federation`, so every bulk row stayed on the originator.
5549        // Signature is now `State<AppState>` and each row is persisted.
5550        let state = test_state();
5551        let app = Router::new()
5552            .route("/api/v1/memories/bulk", axum_post(bulk_create))
5553            .with_state(test_app_state(state.clone()));
5554
5555        let bodies: Vec<serde_json::Value> = (0..5)
5556            .map(|i| {
5557                serde_json::json!({
5558                    "tier": "long",
5559                    "namespace": "bulk-appstate",
5560                    "title": format!("bulk-{i}"),
5561                    "content": format!("body-{i}"),
5562                    "tags": [],
5563                    "priority": 5,
5564                    "confidence": 1.0,
5565                    "source": "api",
5566                    "metadata": {}
5567                })
5568            })
5569            .collect();
5570        let resp = app
5571            .oneshot(
5572                axum::http::Request::builder()
5573                    .uri("/api/v1/memories/bulk")
5574                    .method("POST")
5575                    .header("content-type", "application/json")
5576                    .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
5577                    .unwrap(),
5578            )
5579            .await
5580            .unwrap();
5581        assert_eq!(resp.status(), StatusCode::OK);
5582        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5583            .await
5584            .unwrap();
5585        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5586        assert_eq!(v["created"], 5);
5587        assert!(v["errors"].as_array().unwrap().is_empty());
5588
5589        // Every row is visible in the DB (was the S40 gap — rows never
5590        // made it past the local insert loop, leaving peers empty).
5591        let lock = state.lock().await;
5592        let rows = db::list(
5593            &lock.0,
5594            Some("bulk-appstate"),
5595            None,
5596            100,
5597            0,
5598            None,
5599            None,
5600            None,
5601            None,
5602            None,
5603        )
5604        .unwrap();
5605        assert_eq!(rows.len(), 5, "bulk rows must persist via AppState");
5606    }
5607
5608    #[tokio::test]
5609    async fn http_bulk_create_fans_out_with_federation() {
5610        // S40 — with federation configured, each successfully-inserted row
5611        // in a bulk call must fan out to every peer. We spin up an axum
5612        // mock peer that records sync_push POSTs and bulk-create N rows;
5613        // the mock must see N POSTs (background-detached + foreground).
5614        use std::sync::atomic::{AtomicUsize, Ordering};
5615        use tokio::net::TcpListener;
5616
5617        let state = test_state();
5618
5619        // Mock peer that counts sync_push POSTs and always acks.
5620        let count = Arc::new(AtomicUsize::new(0));
5621        let count_for_peer = count.clone();
5622        #[derive(Clone)]
5623        struct MockState {
5624            count: Arc<AtomicUsize>,
5625        }
5626        async fn mock_sync_push(
5627            axum::extract::State(s): axum::extract::State<MockState>,
5628            Json(_body): Json<serde_json::Value>,
5629        ) -> (StatusCode, Json<serde_json::Value>) {
5630            s.count.fetch_add(1, Ordering::Relaxed);
5631            (
5632                StatusCode::OK,
5633                Json(json!({"applied":1,"noop":0,"skipped":0})),
5634            )
5635        }
5636        let peer_app = Router::new()
5637            .route("/api/v1/sync/push", axum_post(mock_sync_push))
5638            .with_state(MockState {
5639                count: count_for_peer,
5640            });
5641        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5642        let addr = listener.local_addr().unwrap();
5643        tokio::spawn(async move {
5644            axum::serve(listener, peer_app).await.ok();
5645        });
5646
5647        // Build a FederationConfig that targets the mock.
5648        let peer_url = format!("http://{addr}");
5649        let fed = crate::federation::FederationConfig::build(
5650            2, // W=2 — local + 1 peer
5651            &[peer_url],
5652            std::time::Duration::from_secs(2),
5653            None,
5654            None,
5655            None,
5656            "ai:bulk-test".to_string(),
5657        )
5658        .unwrap()
5659        .expect("federation must be built");
5660
5661        let app_state = AppState {
5662            db: state.clone(),
5663            embedder: Arc::new(None),
5664            vector_index: Arc::new(Mutex::new(None)),
5665            federation: Arc::new(Some(fed)),
5666            tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
5667            scoring: Arc::new(crate::config::ResolvedScoring::default()),
5668        };
5669        let router = Router::new()
5670            .route("/api/v1/memories/bulk", axum_post(bulk_create))
5671            .with_state(app_state);
5672
5673        // 4 rows — keeps the test fast while proving fanout ran per-row.
5674        let n = 4;
5675        let bodies: Vec<serde_json::Value> = (0..n)
5676            .map(|i| {
5677                serde_json::json!({
5678                    "tier": "long",
5679                    "namespace": "bulk-fanout",
5680                    "title": format!("bulk-fanout-{i}"),
5681                    "content": "c",
5682                    "tags": [],
5683                    "priority": 5,
5684                    "confidence": 1.0,
5685                    "source": "api",
5686                    "metadata": {}
5687                })
5688            })
5689            .collect();
5690        let resp = router
5691            .oneshot(
5692                axum::http::Request::builder()
5693                    .uri("/api/v1/memories/bulk")
5694                    .method("POST")
5695                    .header("content-type", "application/json")
5696                    .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
5697                    .unwrap(),
5698            )
5699            .await
5700            .unwrap();
5701        assert_eq!(resp.status(), StatusCode::OK);
5702        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5703            .await
5704            .unwrap();
5705        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5706        assert_eq!(v["created"], n);
5707
5708        // Foreground fanout already waits for W-1 acks per row, so the
5709        // per-row POST has landed by the time the request returns. v0.6.2
5710        // Patch 2 (S40) adds a terminal catchup batch — one extra POST
5711        // per peer with the full row set — so the expected total is
5712        // `n + 1` per peer. Give detached stragglers a quick window.
5713        let expected = n + 1;
5714        for _ in 0..20 {
5715            if count.load(Ordering::Relaxed) >= expected {
5716                break;
5717            }
5718            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
5719        }
5720        assert_eq!(
5721            count.load(Ordering::Relaxed),
5722            expected,
5723            "mock peer must receive one sync_push POST per bulk row plus one terminal catchup batch"
5724        );
5725    }
5726
5727    #[tokio::test]
5728    async fn http_sync_push_rejects_oversized_batch_redteam_242() {
5729        // Red-team #242 — sync_push must cap memories per request, matching
5730        // bulk-create's MAX_BULK_SIZE. Without this a malicious peer can
5731        // flood the receiver and bottleneck the SQLite Mutex.
5732        let state = test_state();
5733        let app = Router::new()
5734            .route("/api/v1/sync/push", axum_post(sync_push))
5735            .with_state(test_app_state(state));
5736        let now = Utc::now().to_rfc3339();
5737        // Build MAX_BULK_SIZE + 1 entries (1001).
5738        let mems: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
5739            .map(|i| {
5740                serde_json::json!({
5741                    "id": Uuid::new_v4().to_string(),
5742                    "tier": "long",
5743                    "namespace": "oversize",
5744                    "title": format!("m{i}"),
5745                    "content": "x",
5746                    "tags": [],
5747                    "priority": 5,
5748                    "confidence": 1.0,
5749                    "source": "api",
5750                    "access_count": 0,
5751                    "created_at": now,
5752                    "updated_at": now,
5753                    "last_accessed_at": null,
5754                    "expires_at": null,
5755                    "metadata": {}
5756                })
5757            })
5758            .collect();
5759        let body = serde_json::json!({
5760            "sender_agent_id": "peer-flood",
5761            "sender_clock": {"entries": {}},
5762            "memories": mems,
5763            "dry_run": false,
5764        });
5765        let resp = app
5766            .oneshot(
5767                axum::http::Request::builder()
5768                    .uri("/api/v1/sync/push")
5769                    .method("POST")
5770                    .header("content-type", "application/json")
5771                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5772                    .unwrap(),
5773            )
5774            .await
5775            .unwrap();
5776        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
5777    }
5778
5779    #[tokio::test]
5780    async fn http_sync_push_dry_run_applies_nothing() {
5781        // Phase 3 — dry_run=true must not write.
5782        let state = test_state();
5783        let app = Router::new()
5784            .route("/api/v1/sync/push", axum_post(sync_push))
5785            .with_state(test_app_state(state.clone()));
5786
5787        let now = Utc::now().to_rfc3339();
5788        let body = serde_json::json!({
5789            "sender_agent_id": "peer-bob",
5790            "sender_clock": {"entries": {}},
5791            "memories": [{
5792                "id": Uuid::new_v4().to_string(),
5793                "tier": "long",
5794                "namespace": "sync-dryrun",
5795                "title": "Must not land",
5796                "content": "Preview only.",
5797                "tags": [],
5798                "priority": 5,
5799                "confidence": 1.0,
5800                "source": "api",
5801                "access_count": 0,
5802                "created_at": now,
5803                "updated_at": now,
5804                "last_accessed_at": null,
5805                "expires_at": null,
5806                "metadata": {}
5807            }],
5808            "dry_run": true
5809        });
5810        let resp = app
5811            .oneshot(
5812                axum::http::Request::builder()
5813                    .uri("/api/v1/sync/push")
5814                    .method("POST")
5815                    .header("content-type", "application/json")
5816                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5817                    .unwrap(),
5818            )
5819            .await
5820            .unwrap();
5821        assert_eq!(resp.status(), StatusCode::OK);
5822
5823        let lock = state.lock().await;
5824        let rows = db::list(
5825            &lock.0,
5826            Some("sync-dryrun"),
5827            None,
5828            10,
5829            0,
5830            None,
5831            None,
5832            None,
5833            None,
5834            None,
5835        )
5836        .unwrap();
5837        assert!(rows.is_empty(), "dry_run must not write rows");
5838    }
5839
5840    #[tokio::test]
5841    async fn http_contradictions_surfaces_same_topic_candidates_and_synth_link() {
5842        // v0.6.0.1 (#321) — GET /api/v1/contradictions?topic=X&namespace=Y
5843        // returns the candidate memories sharing the topic and a synthesized
5844        // contradicts link between any pair with differing content.
5845        let state = test_state();
5846        let now = Utc::now().to_rfc3339();
5847
5848        // Seed two memories with metadata.topic=T and DIFFERENT content. We
5849        // use distinct titles so UPSERT-on-(title,namespace) doesn't dedup —
5850        // that's the scenario-6 fix in ai2ai-gate.
5851        {
5852            let lock = state.lock().await;
5853            let topic = "sky-color-test";
5854            for (title, agent, content) in [
5855                ("sky-color-test-alice", "ai:alice", "sky-color-test is blue"),
5856                ("sky-color-test-bob", "ai:bob", "sky-color-test is red"),
5857            ] {
5858                let mem = Memory {
5859                    id: Uuid::new_v4().to_string(),
5860                    tier: Tier::Mid,
5861                    namespace: "contradictions-test".into(),
5862                    title: title.into(),
5863                    content: content.into(),
5864                    tags: vec![],
5865                    priority: 5,
5866                    confidence: 1.0,
5867                    source: "api".into(),
5868                    access_count: 0,
5869                    created_at: now.clone(),
5870                    updated_at: now.clone(),
5871                    last_accessed_at: None,
5872                    expires_at: None,
5873                    metadata: serde_json::json!({
5874                        "agent_id": agent,
5875                        "topic": topic,
5876                    }),
5877                };
5878                db::insert(&lock.0, &mem).unwrap();
5879            }
5880        }
5881
5882        let app = Router::new()
5883            .route("/api/v1/contradictions", axum_get(detect_contradictions))
5884            .with_state(state);
5885
5886        let resp = app
5887            .oneshot(
5888                axum::http::Request::builder()
5889                    .uri(
5890                        "/api/v1/contradictions?topic=sky-color-test&namespace=contradictions-test",
5891                    )
5892                    .body(Body::empty())
5893                    .unwrap(),
5894            )
5895            .await
5896            .unwrap();
5897        assert_eq!(resp.status(), StatusCode::OK);
5898        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5899            .await
5900            .unwrap();
5901        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5902
5903        let memories = v["memories"].as_array().unwrap();
5904        assert_eq!(memories.len(), 2, "both candidates should be returned");
5905
5906        let links = v["links"].as_array().unwrap();
5907        let synth_contradict = links.iter().find(|l| {
5908            l["relation"].as_str() == Some("contradicts")
5909                && l["synthesized"].as_bool() == Some(true)
5910        });
5911        assert!(
5912            synth_contradict.is_some(),
5913            "expected a synthesized contradicts link between alice and bob"
5914        );
5915    }
5916
5917    #[tokio::test]
5918    async fn http_contradictions_requires_topic_or_namespace() {
5919        // Guard: calling the endpoint with neither topic nor namespace is a
5920        // 400 — we refuse to scan the whole DB by accident.
5921        let state = test_state();
5922        let app = Router::new()
5923            .route("/api/v1/contradictions", axum_get(detect_contradictions))
5924            .with_state(state);
5925        let resp = app
5926            .oneshot(
5927                axum::http::Request::builder()
5928                    .uri("/api/v1/contradictions")
5929                    .body(Body::empty())
5930                    .unwrap(),
5931            )
5932            .await
5933            .unwrap();
5934        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
5935    }
5936
5937    #[tokio::test]
5938    async fn http_sync_push_applies_deletions() {
5939        // v0.6.0.1 — sync_push's `deletions` field removes the listed ids
5940        // from the receiver so peer-side tombstone fanout works for
5941        // scenario-10. (a2a-hermes r14.)
5942        let state = test_state();
5943        let now = Utc::now().to_rfc3339();
5944
5945        let seeded_id = {
5946            let lock = state.lock().await;
5947            let mem = Memory {
5948                id: Uuid::new_v4().to_string(),
5949                tier: Tier::Mid,
5950                namespace: "delete-fanout".into(),
5951                title: "to-be-deleted".into(),
5952                content: "body".into(),
5953                tags: vec![],
5954                priority: 5,
5955                confidence: 1.0,
5956                source: "api".into(),
5957                access_count: 0,
5958                created_at: now.clone(),
5959                updated_at: now.clone(),
5960                last_accessed_at: None,
5961                expires_at: None,
5962                metadata: serde_json::json!({"agent_id": "ai:seeder"}),
5963            };
5964            db::insert(&lock.0, &mem).unwrap()
5965        };
5966
5967        let app = Router::new()
5968            .route("/api/v1/sync/push", axum_post(sync_push))
5969            .with_state(test_app_state(state.clone()));
5970
5971        let body = serde_json::json!({
5972            "sender_agent_id": "peer-alice",
5973            "sender_clock": {"entries": {}},
5974            "memories": [],
5975            "deletions": [seeded_id.clone()],
5976            "dry_run": false
5977        });
5978        let resp = app
5979            .oneshot(
5980                axum::http::Request::builder()
5981                    .uri("/api/v1/sync/push")
5982                    .method("POST")
5983                    .header("content-type", "application/json")
5984                    .header("x-agent-id", "local-receiver")
5985                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
5986                    .unwrap(),
5987            )
5988            .await
5989            .unwrap();
5990        assert_eq!(resp.status(), StatusCode::OK);
5991        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5992            .await
5993            .unwrap();
5994        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5995        assert_eq!(v["deleted"], 1);
5996
5997        let lock = state.lock().await;
5998        let gone = db::get(&lock.0, &seeded_id).unwrap();
5999        assert!(
6000            gone.is_none(),
6001            "row should have been tombstoned by sync_push"
6002        );
6003    }
6004
6005    #[tokio::test]
6006    async fn http_sync_push_applies_incoming_links() {
6007        // v0.6.2 (#325) — sync_push's `links` field applies the listed
6008        // (source, target, relation) triples via db::create_link on the
6009        // receiver so peer-side link fanout works for scenario-11.
6010        // (a2a-hermes-v0.6.1-r15.)
6011        let state = test_state();
6012        let now = Utc::now().to_rfc3339();
6013
6014        // Seed two memories on the receiver so the link has valid endpoints.
6015        let (m1, m2) = {
6016            let lock = state.lock().await;
6017            let m1 = Memory {
6018                id: Uuid::new_v4().to_string(),
6019                tier: Tier::Mid,
6020                namespace: "link-fanout".into(),
6021                title: "source".into(),
6022                content: "a".into(),
6023                tags: vec![],
6024                priority: 5,
6025                confidence: 1.0,
6026                source: "api".into(),
6027                access_count: 0,
6028                created_at: now.clone(),
6029                updated_at: now.clone(),
6030                last_accessed_at: None,
6031                expires_at: None,
6032                metadata: serde_json::json!({"agent_id": "ai:seeder"}),
6033            };
6034            let m1_id = db::insert(&lock.0, &m1).unwrap();
6035            let m2 = Memory {
6036                id: Uuid::new_v4().to_string(),
6037                tier: Tier::Mid,
6038                namespace: "link-fanout".into(),
6039                title: "target".into(),
6040                content: "b".into(),
6041                tags: vec![],
6042                priority: 5,
6043                confidence: 1.0,
6044                source: "api".into(),
6045                access_count: 0,
6046                created_at: now.clone(),
6047                updated_at: now.clone(),
6048                last_accessed_at: None,
6049                expires_at: None,
6050                metadata: serde_json::json!({"agent_id": "ai:seeder"}),
6051            };
6052            let m2_id = db::insert(&lock.0, &m2).unwrap();
6053            (m1_id, m2_id)
6054        };
6055
6056        let app = Router::new()
6057            .route("/api/v1/sync/push", axum_post(sync_push))
6058            .with_state(test_app_state(state.clone()));
6059
6060        let body = serde_json::json!({
6061            "sender_agent_id": "peer-alice",
6062            "sender_clock": {"entries": {}},
6063            "memories": [],
6064            "links": [{
6065                "source_id": m1,
6066                "target_id": m2,
6067                "relation": "related_to",
6068                "created_at": now,
6069            }],
6070            "dry_run": false
6071        });
6072        let resp = app
6073            .oneshot(
6074                axum::http::Request::builder()
6075                    .uri("/api/v1/sync/push")
6076                    .method("POST")
6077                    .header("content-type", "application/json")
6078                    .header("x-agent-id", "local-receiver")
6079                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6080                    .unwrap(),
6081            )
6082            .await
6083            .unwrap();
6084        assert_eq!(resp.status(), StatusCode::OK);
6085        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6086            .await
6087            .unwrap();
6088        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6089        assert_eq!(v["links_applied"], 1);
6090
6091        let lock = state.lock().await;
6092        let links = db::get_links(&lock.0, &m1).unwrap();
6093        assert_eq!(links.len(), 1);
6094        assert_eq!(links[0].target_id, m2);
6095        assert_eq!(links[0].relation, "related_to");
6096    }
6097
6098    #[tokio::test]
6099    async fn http_sync_since_streams_new_memories_only() {
6100        // Phase 3 — GET /api/v1/sync/since?since=<ts> returns only memories
6101        // with updated_at > ts.
6102        let state = test_state();
6103        // Seed one old + one new memory.
6104        let old_ts = "2020-01-01T00:00:00+00:00";
6105        let new_ts = Utc::now().to_rfc3339();
6106        {
6107            let lock = state.lock().await;
6108            for (title, ts) in [("old-mem", old_ts), ("new-mem", new_ts.as_str())] {
6109                let mem = Memory {
6110                    id: Uuid::new_v4().to_string(),
6111                    tier: Tier::Long,
6112                    namespace: "since-test".into(),
6113                    title: title.into(),
6114                    content: "body".into(),
6115                    tags: vec![],
6116                    priority: 5,
6117                    confidence: 1.0,
6118                    source: "api".into(),
6119                    access_count: 0,
6120                    created_at: ts.to_string(),
6121                    updated_at: ts.to_string(),
6122                    last_accessed_at: None,
6123                    expires_at: None,
6124                    metadata: serde_json::json!({}),
6125                };
6126                db::insert(&lock.0, &mem).unwrap();
6127            }
6128        }
6129
6130        let app = Router::new()
6131            .route("/api/v1/sync/since", axum_get(sync_since))
6132            .with_state(state);
6133
6134        let resp = app
6135            .oneshot(
6136                axum::http::Request::builder()
6137                    .uri("/api/v1/sync/since?since=2020-06-01T00:00:00%2B00:00")
6138                    .body(Body::empty())
6139                    .unwrap(),
6140            )
6141            .await
6142            .unwrap();
6143        assert_eq!(resp.status(), StatusCode::OK);
6144        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6145            .await
6146            .unwrap();
6147        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6148        let titles: Vec<String> = v["memories"]
6149            .as_array()
6150            .unwrap()
6151            .iter()
6152            .filter_map(|m| m["title"].as_str().map(str::to_string))
6153            .collect();
6154        assert_eq!(titles, vec!["new-mem".to_string()]);
6155    }
6156
6157    #[tokio::test]
6158    async fn http_sync_since_includes_s39_diagnostic_fields() {
6159        // S39 — the response must echo `updated_since` (parsed `since`)
6160        // and earliest/latest `updated_at` from the returned set. This
6161        // lets the scenario pin whether the server saw the expected
6162        // checkpoint without changing the set-returning behavior.
6163        let state = test_state();
6164        // Seed three rows in strictly-ordered time so earliest != latest.
6165        let mid_ts = "2024-06-01T00:00:00+00:00";
6166        let newer_ts = "2025-06-01T00:00:00+00:00";
6167        let newest_ts = "2026-01-01T00:00:00+00:00";
6168        {
6169            let lock = state.lock().await;
6170            for (title, ts) in [("mid", mid_ts), ("newer", newer_ts), ("newest", newest_ts)] {
6171                let mem = Memory {
6172                    id: Uuid::new_v4().to_string(),
6173                    tier: Tier::Long,
6174                    namespace: "s39-diag".into(),
6175                    title: title.into(),
6176                    content: "c".into(),
6177                    tags: vec![],
6178                    priority: 5,
6179                    confidence: 1.0,
6180                    source: "api".into(),
6181                    access_count: 0,
6182                    created_at: ts.to_string(),
6183                    updated_at: ts.to_string(),
6184                    last_accessed_at: None,
6185                    expires_at: None,
6186                    metadata: serde_json::json!({}),
6187                };
6188                db::insert(&lock.0, &mem).unwrap();
6189            }
6190        }
6191
6192        let app = Router::new()
6193            .route("/api/v1/sync/since", axum_get(sync_since))
6194            .with_state(state.clone());
6195
6196        // Ask for rows strictly after 2024-01 — should return all 3.
6197        let since = "2024-01-01T00:00:00%2B00:00";
6198        let resp = app
6199            .oneshot(
6200                axum::http::Request::builder()
6201                    .uri(format!("/api/v1/sync/since?since={since}"))
6202                    .body(Body::empty())
6203                    .unwrap(),
6204            )
6205            .await
6206            .unwrap();
6207        assert_eq!(resp.status(), StatusCode::OK);
6208        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6209            .await
6210            .unwrap();
6211        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6212        assert_eq!(v["count"], 3);
6213        // Echoed `since` (unparsed, verbatim — that's the point).
6214        assert_eq!(v["updated_since"], "2024-01-01T00:00:00+00:00");
6215        assert_eq!(v["earliest_updated_at"], mid_ts);
6216        assert_eq!(v["latest_updated_at"], newest_ts);
6217
6218        // Empty set → both timestamp fields are null. The `updated_since`
6219        // field still echoes the parsed input.
6220        let empty_app = Router::new()
6221            .route("/api/v1/sync/since", axum_get(sync_since))
6222            .with_state(state);
6223        let resp = empty_app
6224            .oneshot(
6225                axum::http::Request::builder()
6226                    .uri("/api/v1/sync/since?since=2099-01-01T00:00:00%2B00:00")
6227                    .body(Body::empty())
6228                    .unwrap(),
6229            )
6230            .await
6231            .unwrap();
6232        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6233            .await
6234            .unwrap();
6235        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6236        assert_eq!(v["count"], 0);
6237        assert!(v["earliest_updated_at"].is_null());
6238        assert!(v["latest_updated_at"].is_null());
6239        assert_eq!(v["updated_since"], "2099-01-01T00:00:00+00:00");
6240    }
6241
6242    #[tokio::test]
6243    async fn sync_since_rejects_garbage_timestamp_with_400() {
6244        // Red-team #247 — `since=garbage` previously returned 200 with all
6245        // memories. Now must return 400 with a clear error.
6246        let state = test_state();
6247        let app = Router::new()
6248            .route("/api/v1/sync/since", axum_get(sync_since))
6249            .with_state(state);
6250
6251        let resp = app
6252            .oneshot(
6253                axum::http::Request::builder()
6254                    .uri("/api/v1/sync/since?since=not-a-date")
6255                    .body(Body::empty())
6256                    .unwrap(),
6257            )
6258            .await
6259            .unwrap();
6260        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6261        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6262            .await
6263            .unwrap();
6264        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6265        assert!(v["error"].as_str().unwrap().contains("RFC 3339"));
6266    }
6267
6268    #[tokio::test]
6269    async fn sync_state_observe_is_monotonic() {
6270        // Phase 3 — clock advancement must never go backwards.
6271        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6272        let older = "2020-01-01T00:00:00+00:00";
6273        let newer = "2026-04-17T00:00:00+00:00";
6274
6275        db::sync_state_observe(&conn, "local", "peer-a", newer).unwrap();
6276        // A subsequent older observation must NOT overwrite.
6277        db::sync_state_observe(&conn, "local", "peer-a", older).unwrap();
6278        let clock = db::sync_state_load(&conn, "local").unwrap();
6279        assert_eq!(clock.latest_from("peer-a"), Some(newer));
6280    }
6281
6282    // --- API key auth middleware tests ---
6283
6284    async fn dummy_handler() -> impl IntoResponse {
6285        (StatusCode::OK, "ok")
6286    }
6287
6288    fn auth_app(api_key: Option<&str>) -> Router {
6289        let auth_state = ApiKeyState {
6290            key: api_key.map(String::from),
6291        };
6292        Router::new()
6293            .route("/api/v1/health", axum_get(dummy_handler))
6294            .route("/api/v1/memories", axum_get(dummy_handler))
6295            .layer(axum::middleware::from_fn_with_state(
6296                auth_state,
6297                api_key_auth,
6298            ))
6299    }
6300
6301    #[tokio::test]
6302    async fn api_key_no_key_configured_allows_all() {
6303        let app = auth_app(None);
6304        let resp = app
6305            .oneshot(
6306                axum::http::Request::builder()
6307                    .uri("/api/v1/memories")
6308                    .body(Body::empty())
6309                    .unwrap(),
6310            )
6311            .await
6312            .unwrap();
6313        assert_eq!(resp.status(), StatusCode::OK);
6314    }
6315
6316    #[tokio::test]
6317    async fn api_key_valid_header_allows() {
6318        let app = auth_app(Some("secret123"));
6319        let resp = app
6320            .oneshot(
6321                axum::http::Request::builder()
6322                    .uri("/api/v1/memories")
6323                    .header("x-api-key", "secret123")
6324                    .body(Body::empty())
6325                    .unwrap(),
6326            )
6327            .await
6328            .unwrap();
6329        assert_eq!(resp.status(), StatusCode::OK);
6330    }
6331
6332    #[tokio::test]
6333    async fn api_key_invalid_header_rejected() {
6334        let app = auth_app(Some("secret123"));
6335        let resp = app
6336            .oneshot(
6337                axum::http::Request::builder()
6338                    .uri("/api/v1/memories")
6339                    .header("x-api-key", "wrong")
6340                    .body(Body::empty())
6341                    .unwrap(),
6342            )
6343            .await
6344            .unwrap();
6345        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6346    }
6347
6348    #[tokio::test]
6349    async fn api_key_missing_header_rejected() {
6350        let app = auth_app(Some("secret123"));
6351        let resp = app
6352            .oneshot(
6353                axum::http::Request::builder()
6354                    .uri("/api/v1/memories")
6355                    .body(Body::empty())
6356                    .unwrap(),
6357            )
6358            .await
6359            .unwrap();
6360        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6361    }
6362
6363    #[tokio::test]
6364    async fn api_key_valid_query_param_allows() {
6365        let app = auth_app(Some("secret123"));
6366        let resp = app
6367            .oneshot(
6368                axum::http::Request::builder()
6369                    .uri("/api/v1/memories?api_key=secret123")
6370                    .body(Body::empty())
6371                    .unwrap(),
6372            )
6373            .await
6374            .unwrap();
6375        assert_eq!(resp.status(), StatusCode::OK);
6376    }
6377
6378    #[tokio::test]
6379    async fn api_key_health_exempt() {
6380        let app = auth_app(Some("secret123"));
6381        let resp = app
6382            .oneshot(
6383                axum::http::Request::builder()
6384                    .uri("/api/v1/health")
6385                    .body(Body::empty())
6386                    .unwrap(),
6387            )
6388            .await
6389            .unwrap();
6390        assert_eq!(resp.status(), StatusCode::OK);
6391    }
6392    // --- Error arm unit tests (cov-80pct/handlers-errors) ---
6393    // Target the 30% of handlers.rs that smoke tests don't reach:
6394    // Axum extractor failures, domain validation errors, governance rejections,
6395    // SSRF defense, and streaming error paths.
6396
6397    // ---- Axum extractor failures: invalid JSON, missing fields, oversized body ----
6398
6399    #[tokio::test]
6400    async fn create_memory_rejects_invalid_json() {
6401        let state = test_state();
6402        let app = Router::new()
6403            .route("/api/v1/memories", axum_post(create_memory))
6404            .with_state(test_app_state(state));
6405
6406        let resp = app
6407            .oneshot(
6408                axum::http::Request::builder()
6409                    .uri("/api/v1/memories")
6410                    .method("POST")
6411                    .header("content-type", "application/json")
6412                    .body(Body::from(b"not valid json".to_vec()))
6413                    .unwrap(),
6414            )
6415            .await
6416            .unwrap();
6417        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6418    }
6419
6420    #[tokio::test]
6421    async fn create_memory_rejects_missing_required_fields() {
6422        let state = test_state();
6423        let app = Router::new()
6424            .route("/api/v1/memories", axum_post(create_memory))
6425            .with_state(test_app_state(state));
6426
6427        // Missing title
6428        let body = serde_json::json!({
6429            "tier": "long",
6430            "namespace": "test",
6431            "content": "body text",
6432            "tags": [],
6433            "priority": 5,
6434            "confidence": 1.0,
6435            "source": "api",
6436            "metadata": {}
6437        });
6438        let resp = app
6439            .clone()
6440            .oneshot(
6441                axum::http::Request::builder()
6442                    .uri("/api/v1/memories")
6443                    .method("POST")
6444                    .header("content-type", "application/json")
6445                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6446                    .unwrap(),
6447            )
6448            .await
6449            .unwrap();
6450        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
6451    }
6452
6453    #[tokio::test]
6454    async fn create_memory_rejects_empty_title() {
6455        let state = test_state();
6456        let app = Router::new()
6457            .route("/api/v1/memories", axum_post(create_memory))
6458            .with_state(test_app_state(state));
6459
6460        let body = serde_json::json!({
6461            "tier": "long",
6462            "namespace": "test",
6463            "title": "",
6464            "content": "body text",
6465            "tags": [],
6466            "priority": 5,
6467            "confidence": 1.0,
6468            "source": "api",
6469            "metadata": {}
6470        });
6471        let resp = app
6472            .oneshot(
6473                axum::http::Request::builder()
6474                    .uri("/api/v1/memories")
6475                    .method("POST")
6476                    .header("content-type", "application/json")
6477                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6478                    .unwrap(),
6479            )
6480            .await
6481            .unwrap();
6482        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6483        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6484            .await
6485            .unwrap();
6486        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6487        assert!(v["error"].as_str().unwrap().contains("title"));
6488    }
6489
6490    #[tokio::test]
6491    async fn create_memory_rejects_oversized_content() {
6492        let state = test_state();
6493        let app = Router::new()
6494            .route("/api/v1/memories", axum_post(create_memory))
6495            .with_state(test_app_state(state));
6496
6497        // 65KB + 1 — exceeds MAX_CONTENT_SIZE (65536)
6498        let oversized = "x".repeat(65537);
6499        let body = serde_json::json!({
6500            "tier": "long",
6501            "namespace": "test",
6502            "title": "Test",
6503            "content": oversized,
6504            "tags": [],
6505            "priority": 5,
6506            "confidence": 1.0,
6507            "source": "api",
6508            "metadata": {}
6509        });
6510        let resp = app
6511            .oneshot(
6512                axum::http::Request::builder()
6513                    .uri("/api/v1/memories")
6514                    .method("POST")
6515                    .header("content-type", "application/json")
6516                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6517                    .unwrap(),
6518            )
6519            .await
6520            .unwrap();
6521        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6522        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6523            .await
6524            .unwrap();
6525        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6526        assert!(v["error"].as_str().unwrap().contains("exceeds max size"));
6527    }
6528
6529    #[tokio::test]
6530    async fn create_memory_rejects_invalid_tier() {
6531        let state = test_state();
6532        let app = Router::new()
6533            .route("/api/v1/memories", axum_post(create_memory))
6534            .with_state(test_app_state(state));
6535
6536        // Invalid tier enum value
6537        let body_str = r#"{"tier":"invalid_tier","namespace":"test","title":"Test","content":"body","tags":[],"priority":5,"confidence":1.0,"source":"api","metadata":{}}"#;
6538        let resp = app
6539            .oneshot(
6540                axum::http::Request::builder()
6541                    .uri("/api/v1/memories")
6542                    .method("POST")
6543                    .header("content-type", "application/json")
6544                    .body(Body::from(body_str.as_bytes().to_vec()))
6545                    .unwrap(),
6546            )
6547            .await
6548            .unwrap();
6549        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
6550    }
6551
6552    #[tokio::test]
6553    async fn create_memory_rejects_invalid_priority() {
6554        let state = test_state();
6555        let app = Router::new()
6556            .route("/api/v1/memories", axum_post(create_memory))
6557            .with_state(test_app_state(state));
6558
6559        let body = serde_json::json!({
6560            "tier": "long",
6561            "namespace": "test",
6562            "title": "Test",
6563            "content": "body",
6564            "tags": [],
6565            "priority": 0,  // min is 1
6566            "confidence": 1.0,
6567            "source": "api",
6568            "metadata": {}
6569        });
6570        let resp = app
6571            .oneshot(
6572                axum::http::Request::builder()
6573                    .uri("/api/v1/memories")
6574                    .method("POST")
6575                    .header("content-type", "application/json")
6576                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6577                    .unwrap(),
6578            )
6579            .await
6580            .unwrap();
6581        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6582    }
6583
6584    #[tokio::test]
6585    async fn create_memory_rejects_invalid_confidence() {
6586        let state = test_state();
6587        let app = Router::new()
6588            .route("/api/v1/memories", axum_post(create_memory))
6589            .with_state(test_app_state(state));
6590
6591        let body = serde_json::json!({
6592            "tier": "long",
6593            "namespace": "test",
6594            "title": "Test",
6595            "content": "body",
6596            "tags": [],
6597            "priority": 5,
6598            "confidence": 1.5,  // must be 0.0-1.0
6599            "source": "api",
6600            "metadata": {}
6601        });
6602        let resp = app
6603            .oneshot(
6604                axum::http::Request::builder()
6605                    .uri("/api/v1/memories")
6606                    .method("POST")
6607                    .header("content-type", "application/json")
6608                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6609                    .unwrap(),
6610            )
6611            .await
6612            .unwrap();
6613        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6614    }
6615
6616    #[tokio::test]
6617    async fn create_memory_rejects_invalid_source() {
6618        let state = test_state();
6619        let app = Router::new()
6620            .route("/api/v1/memories", axum_post(create_memory))
6621            .with_state(test_app_state(state));
6622
6623        let body = serde_json::json!({
6624            "tier": "long",
6625            "namespace": "test",
6626            "title": "Test",
6627            "content": "body",
6628            "tags": [],
6629            "priority": 5,
6630            "confidence": 1.0,
6631            "source": "invalid_source",
6632            "metadata": {}
6633        });
6634        let resp = app
6635            .oneshot(
6636                axum::http::Request::builder()
6637                    .uri("/api/v1/memories")
6638                    .method("POST")
6639                    .header("content-type", "application/json")
6640                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6641                    .unwrap(),
6642            )
6643            .await
6644            .unwrap();
6645        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6646    }
6647
6648    // ---- update_memory errors ----
6649
6650    #[tokio::test]
6651    async fn update_memory_rejects_invalid_id() {
6652        let state = test_state();
6653        let app = Router::new()
6654            .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6655            .with_state(test_app_state(state));
6656
6657        let body = serde_json::json!({"content": "new content"});
6658        // Test with a URL path that's invalid (most long IDs in memory system are UUIDs,
6659        // which are fixed 36 chars, so a very long string validates but doesn't exist -> 404)
6660        // Let's use a different approach: an ID with invalid characters
6661        let resp = app
6662            .oneshot(
6663                axum::http::Request::builder()
6664                    .uri("/api/v1/memories/@@@@@@@@@@@@") // invalid characters
6665                    .method("PUT")
6666                    .header("content-type", "application/json")
6667                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6668                    .unwrap(),
6669            )
6670            .await
6671            .unwrap();
6672        // Invalid characters in ID should return BAD_REQUEST from validation
6673        assert!(resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::NOT_FOUND);
6674    }
6675
6676    #[tokio::test]
6677    async fn update_memory_rejects_oversized_content() {
6678        let state = test_state();
6679        let now = Utc::now();
6680        let id = {
6681            let lock = state.lock().await;
6682            let mem = Memory {
6683                id: Uuid::new_v4().to_string(),
6684                tier: Tier::Long,
6685                namespace: "test".into(),
6686                title: "To Update".into(),
6687                content: "Original".into(),
6688                tags: vec![],
6689                priority: 5,
6690                confidence: 1.0,
6691                source: "test".into(),
6692                access_count: 0,
6693                created_at: now.to_rfc3339(),
6694                updated_at: now.to_rfc3339(),
6695                last_accessed_at: None,
6696                expires_at: None,
6697                metadata: serde_json::json!({}),
6698            };
6699            db::insert(&lock.0, &mem).unwrap()
6700        };
6701
6702        let app = Router::new()
6703            .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6704            .with_state(test_app_state(state));
6705
6706        let oversized = "x".repeat(65537);
6707        let body = serde_json::json!({"content": oversized});
6708        let resp = app
6709            .oneshot(
6710                axum::http::Request::builder()
6711                    .uri(format!("/api/v1/memories/{id}"))
6712                    .method("PUT")
6713                    .header("content-type", "application/json")
6714                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6715                    .unwrap(),
6716            )
6717            .await
6718            .unwrap();
6719        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6720    }
6721
6722    #[tokio::test]
6723    async fn update_memory_rejects_invalid_confidence() {
6724        let state = test_state();
6725        let now = Utc::now();
6726        let id = {
6727            let lock = state.lock().await;
6728            let mem = Memory {
6729                id: Uuid::new_v4().to_string(),
6730                tier: Tier::Long,
6731                namespace: "test".into(),
6732                title: "To Update".into(),
6733                content: "Original".into(),
6734                tags: vec![],
6735                priority: 5,
6736                confidence: 1.0,
6737                source: "test".into(),
6738                access_count: 0,
6739                created_at: now.to_rfc3339(),
6740                updated_at: now.to_rfc3339(),
6741                last_accessed_at: None,
6742                expires_at: None,
6743                metadata: serde_json::json!({}),
6744            };
6745            db::insert(&lock.0, &mem).unwrap()
6746        };
6747
6748        let app = Router::new()
6749            .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6750            .with_state(test_app_state(state));
6751
6752        let body = serde_json::json!({"confidence": -0.5});
6753        let resp = app
6754            .oneshot(
6755                axum::http::Request::builder()
6756                    .uri(format!("/api/v1/memories/{id}"))
6757                    .method("PUT")
6758                    .header("content-type", "application/json")
6759                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6760                    .unwrap(),
6761            )
6762            .await
6763            .unwrap();
6764        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6765    }
6766
6767    // ---- link validation errors ----
6768
6769    #[tokio::test]
6770    async fn link_rejects_self_link() {
6771        let state = test_state();
6772        let app = Router::new()
6773            .route("/api/v1/links", axum_post(create_link))
6774            .with_state(test_app_state(state));
6775
6776        let same_id = Uuid::new_v4().to_string();
6777        let body = serde_json::json!({
6778            "source_id": same_id,
6779            "target_id": same_id,
6780            "relation": "related_to"
6781        });
6782        let resp = app
6783            .oneshot(
6784                axum::http::Request::builder()
6785                    .uri("/api/v1/links")
6786                    .method("POST")
6787                    .header("content-type", "application/json")
6788                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6789                    .unwrap(),
6790            )
6791            .await
6792            .unwrap();
6793        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6794        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6795            .await
6796            .unwrap();
6797        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6798        assert!(
6799            v["error"]
6800                .as_str()
6801                .unwrap()
6802                .contains("cannot link a memory to itself")
6803        );
6804    }
6805
6806    #[tokio::test]
6807    async fn link_rejects_unknown_relation() {
6808        let state = test_state();
6809        let app = Router::new()
6810            .route("/api/v1/links", axum_post(create_link))
6811            .with_state(test_app_state(state));
6812
6813        let body = serde_json::json!({
6814            "source_id": Uuid::new_v4().to_string(),
6815            "target_id": Uuid::new_v4().to_string(),
6816            "relation": "invalid_relation"
6817        });
6818        let resp = app
6819            .oneshot(
6820                axum::http::Request::builder()
6821                    .uri("/api/v1/links")
6822                    .method("POST")
6823                    .header("content-type", "application/json")
6824                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6825                    .unwrap(),
6826            )
6827            .await
6828            .unwrap();
6829        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6830        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6831            .await
6832            .unwrap();
6833        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6834        assert!(v["error"].as_str().unwrap().contains("relation"));
6835    }
6836
6837    // ---- recall validation errors ----
6838
6839    #[tokio::test]
6840    async fn recall_post_rejects_empty_context() {
6841        let state = test_state();
6842        let app = Router::new()
6843            .route("/api/v1/memories/recall", axum_post(recall_memories_post))
6844            .with_state(test_app_state(state));
6845
6846        let body = serde_json::json!({
6847            "context": "",
6848            "limit": 10
6849        });
6850        let resp = app
6851            .oneshot(
6852                axum::http::Request::builder()
6853                    .uri("/api/v1/memories/recall")
6854                    .method("POST")
6855                    .header("content-type", "application/json")
6856                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6857                    .unwrap(),
6858            )
6859            .await
6860            .unwrap();
6861        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6862    }
6863
6864    #[tokio::test]
6865    async fn recall_post_zero_budget_tokens_returns_empty() {
6866        // Phase P6 (R1): budget_tokens=0 is a valid request meaning
6867        // "give me nothing"; returns 200 with an empty memories array
6868        // and meta.budget_overflow=false. Supersedes the v0.6.3
6869        // Ultrareview #348 hard-reject of 0.
6870        let state = test_state();
6871        let app = Router::new()
6872            .route("/api/v1/memories/recall", axum_post(recall_memories_post))
6873            .with_state(test_app_state(state));
6874
6875        let body = serde_json::json!({
6876            "context": "search term",
6877            "limit": 10,
6878            "budget_tokens": 0
6879        });
6880        let resp = app
6881            .oneshot(
6882                axum::http::Request::builder()
6883                    .uri("/api/v1/memories/recall")
6884                    .method("POST")
6885                    .header("content-type", "application/json")
6886                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6887                    .unwrap(),
6888            )
6889            .await
6890            .unwrap();
6891        assert_eq!(resp.status(), StatusCode::OK);
6892        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6893            .await
6894            .unwrap();
6895        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6896        assert_eq!(v["count"], 0, "budget_tokens=0 returns zero memories");
6897        assert_eq!(v["budget_tokens"], 0);
6898        assert_eq!(v["meta"]["budget_overflow"], false);
6899    }
6900
6901    #[tokio::test]
6902    async fn recall_get_rejects_empty_context() {
6903        let state = test_state();
6904        let app = Router::new()
6905            .route(
6906                "/api/v1/memories/recall",
6907                axum::routing::get(recall_memories_get),
6908            )
6909            .with_state(test_app_state(state));
6910
6911        let resp = app
6912            .oneshot(
6913                axum::http::Request::builder()
6914                    .uri("/api/v1/memories/recall?context=")
6915                    .method("GET")
6916                    .body(Body::empty())
6917                    .unwrap(),
6918            )
6919            .await
6920            .unwrap();
6921        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6922    }
6923
6924    // ---- register_agent validation errors ----
6925
6926    #[tokio::test]
6927    async fn register_agent_rejects_invalid_agent_id() {
6928        let state = test_state();
6929        let app = Router::new()
6930            .route("/api/v1/agents", axum_post(register_agent))
6931            .with_state(test_app_state(state));
6932
6933        let body = serde_json::json!({
6934            "agent_id": "x".repeat(129),  // exceeds max 128
6935            "agent_type": "human",
6936            "capabilities": []
6937        });
6938        let resp = app
6939            .oneshot(
6940                axum::http::Request::builder()
6941                    .uri("/api/v1/agents")
6942                    .method("POST")
6943                    .header("content-type", "application/json")
6944                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6945                    .unwrap(),
6946            )
6947            .await
6948            .unwrap();
6949        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6950    }
6951
6952    #[tokio::test]
6953    async fn register_agent_rejects_invalid_agent_type() {
6954        let state = test_state();
6955        let app = Router::new()
6956            .route("/api/v1/agents", axum_post(register_agent))
6957            .with_state(test_app_state(state));
6958
6959        let body = serde_json::json!({
6960            "agent_id": "test-agent",
6961            "agent_type": "invalid_type",
6962            "capabilities": []
6963        });
6964        let resp = app
6965            .oneshot(
6966                axum::http::Request::builder()
6967                    .uri("/api/v1/agents")
6968                    .method("POST")
6969                    .header("content-type", "application/json")
6970                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
6971                    .unwrap(),
6972            )
6973            .await
6974            .unwrap();
6975        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6976    }
6977
6978    // ---- subscribe validation (SSRF defense) ----
6979
6980    #[tokio::test]
6981    async fn subscribe_rejects_private_ip() {
6982        let state = test_state();
6983        let app = Router::new()
6984            .route("/api/v1/subscriptions", axum_post(subscribe))
6985            .with_state(test_app_state(state));
6986
6987        // Private IP range: http:// to non-loopback requires https
6988        let body = serde_json::json!({
6989            "url": "http://10.0.0.1/webhook",
6990            "events": "*"
6991        });
6992        let resp = app
6993            .oneshot(
6994                axum::http::Request::builder()
6995                    .uri("/api/v1/subscriptions")
6996                    .method("POST")
6997                    .header("content-type", "application/json")
6998                    .header("x-agent-id", "alice")
6999                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7000                    .unwrap(),
7001            )
7002            .await
7003            .unwrap();
7004        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7005        // The error could be about private IPs or about non-https for non-loopback
7006        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7007            .await
7008            .unwrap();
7009        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7010        let error_msg = v["error"].as_str().unwrap();
7011        assert!(
7012            error_msg.contains("private")
7013                || error_msg.contains("link-local")
7014                || error_msg.contains("https")
7015                || error_msg.contains("non-loopback")
7016        );
7017    }
7018
7019    #[tokio::test]
7020    async fn subscribe_rejects_file_url() {
7021        let state = test_state();
7022        let app = Router::new()
7023            .route("/api/v1/subscriptions", axum_post(subscribe))
7024            .with_state(test_app_state(state));
7025
7026        let body = serde_json::json!({
7027            "url": "file:///etc/passwd",
7028            "events": "*"
7029        });
7030        let resp = app
7031            .oneshot(
7032                axum::http::Request::builder()
7033                    .uri("/api/v1/subscriptions")
7034                    .method("POST")
7035                    .header("content-type", "application/json")
7036                    .header("x-agent-id", "alice")
7037                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7038                    .unwrap(),
7039            )
7040            .await
7041            .unwrap();
7042        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7043    }
7044
7045    #[tokio::test]
7046    async fn subscribe_accepts_localhost_loopback() {
7047        // Localhost is explicitly allowed for S33 namespace-subscribe pattern
7048        let state = test_state();
7049        let app = Router::new()
7050            .route("/api/v1/subscriptions", axum_post(subscribe))
7051            .with_state(test_app_state(state));
7052
7053        let body = serde_json::json!({
7054            "url": "http://localhost/webhook",
7055            "events": "*"
7056        });
7057        let resp = app
7058            .oneshot(
7059                axum::http::Request::builder()
7060                    .uri("/api/v1/subscriptions")
7061                    .method("POST")
7062                    .header("content-type", "application/json")
7063                    .header("x-agent-id", "alice")
7064                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7065                    .unwrap(),
7066            )
7067            .await
7068            .unwrap();
7069        // Should succeed or fail gracefully (may fail if DB insert fails, but not SSRF)
7070        // Localhost is explicitly allowed for S33
7071        assert!(resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK);
7072    }
7073
7074    // ---- notify validation errors ----
7075
7076    #[tokio::test]
7077    async fn notify_rejects_missing_payload() {
7078        let state = test_state();
7079        let app = Router::new()
7080            .route("/api/v1/notify", axum_post(notify))
7081            .with_state(test_app_state(state));
7082
7083        let body = serde_json::json!({
7084            "target_agent_id": "bob",
7085            "title": "A message"
7086        });
7087        let resp = app
7088            .oneshot(
7089                axum::http::Request::builder()
7090                    .uri("/api/v1/notify")
7091                    .method("POST")
7092                    .header("content-type", "application/json")
7093                    .header("x-agent-id", "alice")
7094                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7095                    .unwrap(),
7096            )
7097            .await
7098            .unwrap();
7099        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7100        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7101            .await
7102            .unwrap();
7103        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7104        assert!(
7105            v["error"].as_str().unwrap().contains("payload")
7106                || v["error"].as_str().unwrap().contains("content")
7107        );
7108    }
7109
7110    // ---- governance rejection (Task 1.9) ----
7111    // Note: Full governance enforcement requires DB setup with actual governance
7112    // policies. These tests verify the handler path exists and returns 422/403.
7113    // Skipped here due to complexity — documented in escape hatch.
7114
7115    // ---- Content-Type negotiation ----
7116
7117    #[tokio::test]
7118    async fn create_memory_handles_missing_content_type() {
7119        let state = test_state();
7120        let app = Router::new()
7121            .route("/api/v1/memories", axum_post(create_memory))
7122            .with_state(test_app_state(state));
7123
7124        let body = serde_json::json!({
7125            "tier": "long",
7126            "namespace": "test",
7127            "title": "Test",
7128            "content": "body",
7129            "tags": [],
7130            "priority": 5,
7131            "confidence": 1.0,
7132            "source": "api",
7133            "metadata": {}
7134        });
7135        // Omit content-type header
7136        let resp = app
7137            .oneshot(
7138                axum::http::Request::builder()
7139                    .uri("/api/v1/memories")
7140                    .method("POST")
7141                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7142                    .unwrap(),
7143            )
7144            .await
7145            .unwrap();
7146        // Should fail (Axum rejects without content-type)
7147        assert!(resp.status() != StatusCode::CREATED);
7148    }
7149
7150    // ---- Pagination edge cases ----
7151
7152    #[tokio::test]
7153    async fn list_memories_handles_limit_zero() {
7154        let state = test_state();
7155        let app = Router::new()
7156            .route("/api/v1/memories", axum::routing::get(list_memories))
7157            .with_state(test_app_state(state));
7158
7159        let resp = app
7160            .oneshot(
7161                axum::http::Request::builder()
7162                    .uri("/api/v1/memories?limit=0")
7163                    .method("GET")
7164                    .body(Body::empty())
7165                    .unwrap(),
7166            )
7167            .await
7168            .unwrap();
7169        // Should succeed with default limit (not error)
7170        assert_eq!(resp.status(), StatusCode::OK);
7171    }
7172
7173    #[tokio::test]
7174    async fn list_memories_clamps_oversized_limit() {
7175        let state = test_state();
7176        let app = Router::new()
7177            .route("/api/v1/memories", axum::routing::get(list_memories))
7178            .with_state(test_app_state(state));
7179
7180        let resp = app
7181            .oneshot(
7182                axum::http::Request::builder()
7183                    .uri("/api/v1/memories?limit=10000") // way over normal max
7184                    .method("GET")
7185                    .body(Body::empty())
7186                    .unwrap(),
7187            )
7188            .await
7189            .unwrap();
7190        // Should succeed with clamped limit
7191        assert_eq!(resp.status(), StatusCode::OK);
7192    }
7193
7194    #[tokio::test]
7195    async fn search_memories_handles_negative_limit() {
7196        let state = test_state();
7197        let app = Router::new()
7198            .route(
7199                "/api/v1/memories/search",
7200                axum::routing::get(search_memories),
7201            )
7202            .with_state(test_app_state(state));
7203
7204        let resp = app
7205            .oneshot(
7206                axum::http::Request::builder()
7207                    .uri("/api/v1/memories/search?query=test&limit=-1")
7208                    .method("GET")
7209                    .body(Body::empty())
7210                    .unwrap(),
7211            )
7212            .await
7213            .unwrap();
7214        // Should not crash; may be treated as 0 or clamped
7215        assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST);
7216    }
7217
7218    // ---- API Key authentication errors ----
7219
7220    #[tokio::test]
7221    async fn api_key_missing_when_required_rejects() {
7222        let app = auth_app(Some("secret123"));
7223        let resp = app
7224            .oneshot(
7225                axum::http::Request::builder()
7226                    .uri("/api/v1/memories")
7227                    .method("GET")
7228                    // No x-api-key header
7229                    .body(Body::empty())
7230                    .unwrap(),
7231            )
7232            .await
7233            .unwrap();
7234        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7235    }
7236
7237    #[tokio::test]
7238    async fn api_key_wrong_value_rejects() {
7239        let app = auth_app(Some("secret123"));
7240        let resp = app
7241            .oneshot(
7242                axum::http::Request::builder()
7243                    .uri("/api/v1/memories")
7244                    .method("GET")
7245                    .header("x-api-key", "wrong_secret")
7246                    .body(Body::empty())
7247                    .unwrap(),
7248            )
7249            .await
7250            .unwrap();
7251        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7252    }
7253
7254    // ---------------------------------------------------------------
7255    // Wave 2 Closer Z — targeted tests for the 30% past A2's smoke
7256    // matrix and Agent D's error arms. Focuses on lifecycle edge
7257    // cases (archive/restore/purge), bulk partial-success, format
7258    // negotiation, and pending workflows.
7259    // ---------------------------------------------------------------
7260
7261    /// Insert a memory directly via the DB layer; returns the id.
7262    async fn insert_test_memory(state: &Db, namespace: &str, title: &str) -> String {
7263        let lock = state.lock().await;
7264        let now = Utc::now().to_rfc3339();
7265        let mem = Memory {
7266            id: Uuid::new_v4().to_string(),
7267            tier: Tier::Long,
7268            namespace: namespace.into(),
7269            title: title.into(),
7270            content: format!("content for {title}"),
7271            tags: vec![],
7272            priority: 5,
7273            confidence: 1.0,
7274            source: "test".into(),
7275            access_count: 0,
7276            created_at: now.clone(),
7277            updated_at: now,
7278            last_accessed_at: None,
7279            expires_at: None,
7280            metadata: serde_json::json!({}),
7281        };
7282        db::insert(&lock.0, &mem).unwrap()
7283    }
7284
7285    // ---- Archive lifecycle edge cases ----
7286
7287    #[tokio::test]
7288    async fn http_list_archive_rejects_limit_zero() {
7289        let state = test_state();
7290        let app = Router::new()
7291            .route("/api/v1/archive", axum::routing::get(list_archive))
7292            .with_state(state);
7293        let resp = app
7294            .oneshot(
7295                axum::http::Request::builder()
7296                    .uri("/api/v1/archive?limit=0")
7297                    .body(Body::empty())
7298                    .unwrap(),
7299            )
7300            .await
7301            .unwrap();
7302        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7303        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7304            .await
7305            .unwrap();
7306        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7307        assert!(v["error"].as_str().unwrap().contains("limit"));
7308    }
7309
7310    #[tokio::test]
7311    async fn http_list_archive_clamps_oversized_limit() {
7312        let state = test_state();
7313        let app = Router::new()
7314            .route("/api/v1/archive", axum::routing::get(list_archive))
7315            .with_state(state);
7316        let resp = app
7317            .oneshot(
7318                axum::http::Request::builder()
7319                    .uri("/api/v1/archive?limit=99999")
7320                    .body(Body::empty())
7321                    .unwrap(),
7322            )
7323            .await
7324            .unwrap();
7325        assert_eq!(resp.status(), StatusCode::OK);
7326    }
7327
7328    #[tokio::test]
7329    async fn http_list_archive_filters_by_namespace() {
7330        let state = test_state();
7331        // Archive one row under a specific namespace.
7332        let id = insert_test_memory(&state, "arch-ns-a", "to-archive").await;
7333        {
7334            let lock = state.lock().await;
7335            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7336        }
7337        let app = Router::new()
7338            .route("/api/v1/archive", axum::routing::get(list_archive))
7339            .with_state(state);
7340        let resp = app
7341            .oneshot(
7342                axum::http::Request::builder()
7343                    .uri("/api/v1/archive?namespace=arch-ns-a&limit=10")
7344                    .body(Body::empty())
7345                    .unwrap(),
7346            )
7347            .await
7348            .unwrap();
7349        assert_eq!(resp.status(), StatusCode::OK);
7350        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7351            .await
7352            .unwrap();
7353        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7354        assert_eq!(v["count"], 1);
7355    }
7356
7357    #[tokio::test]
7358    async fn http_restore_archive_404_for_unknown_id() {
7359        let state = test_state();
7360        let app = Router::new()
7361            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7362            .with_state(test_app_state(state));
7363        let resp = app
7364            .oneshot(
7365                axum::http::Request::builder()
7366                    .uri("/api/v1/archive/00000000-0000-0000-0000-000000000000/restore")
7367                    .method("POST")
7368                    .body(Body::empty())
7369                    .unwrap(),
7370            )
7371            .await
7372            .unwrap();
7373        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7374    }
7375
7376    #[tokio::test]
7377    async fn http_restore_archive_rejects_empty_id() {
7378        // validate_id rejects whitespace-only / control-char inputs.
7379        // We use a control char via percent-encoding (%01) which makes
7380        // the path parse as an id (not "skip route") but fail
7381        // validate_id's clean-string check.
7382        let state = test_state();
7383        let app = Router::new()
7384            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7385            .with_state(test_app_state(state));
7386        let resp = app
7387            .oneshot(
7388                axum::http::Request::builder()
7389                    .uri("/api/v1/archive/%01/restore")
7390                    .method("POST")
7391                    .body(Body::empty())
7392                    .unwrap(),
7393            )
7394            .await
7395            .unwrap();
7396        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7397    }
7398
7399    #[tokio::test]
7400    async fn http_restore_archive_double_restore_returns_404() {
7401        // Restore happy-path then try to restore again — second call must
7402        // 404 because the row is no longer in archived_memories.
7403        let state = test_state();
7404        let id = insert_test_memory(&state, "restore-twice", "row").await;
7405        {
7406            let lock = state.lock().await;
7407            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7408        }
7409        let app = Router::new()
7410            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7411            .with_state(test_app_state(state.clone()));
7412
7413        // First restore succeeds.
7414        let resp = app
7415            .clone()
7416            .oneshot(
7417                axum::http::Request::builder()
7418                    .uri(format!("/api/v1/archive/{id}/restore"))
7419                    .method("POST")
7420                    .body(Body::empty())
7421                    .unwrap(),
7422            )
7423            .await
7424            .unwrap();
7425        assert_eq!(resp.status(), StatusCode::OK);
7426
7427        // Second restore — already restored, must 404.
7428        let resp = app
7429            .oneshot(
7430                axum::http::Request::builder()
7431                    .uri(format!("/api/v1/archive/{id}/restore"))
7432                    .method("POST")
7433                    .body(Body::empty())
7434                    .unwrap(),
7435            )
7436            .await
7437            .unwrap();
7438        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7439    }
7440
7441    #[tokio::test]
7442    async fn http_purge_archive_zero_days_purges_all() {
7443        // older_than_days=0 means "older than 0 days ago" — purges
7444        // every archive row whose archived_at < now (i.e., everything).
7445        let state = test_state();
7446        let id = insert_test_memory(&state, "purge-zero", "x").await;
7447        {
7448            let lock = state.lock().await;
7449            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7450        }
7451        let app = Router::new()
7452            .route("/api/v1/archive/purge", axum_post(purge_archive))
7453            .with_state(state.clone());
7454        let resp = app
7455            .oneshot(
7456                axum::http::Request::builder()
7457                    .uri("/api/v1/archive/purge?older_than_days=0")
7458                    .method("POST")
7459                    .body(Body::empty())
7460                    .unwrap(),
7461            )
7462            .await
7463            .unwrap();
7464        assert_eq!(resp.status(), StatusCode::OK);
7465        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7466            .await
7467            .unwrap();
7468        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7469        // older_than_days=0 with a freshly archived row may or may not
7470        // include it depending on clock resolution; either way the call
7471        // must succeed and the response must report a usize count.
7472        assert!(v["purged"].as_u64().is_some());
7473    }
7474
7475    #[tokio::test]
7476    async fn http_purge_archive_negative_days_returns_500() {
7477        // db::purge_archive bails on negative days; handler maps to 500.
7478        let state = test_state();
7479        let app = Router::new()
7480            .route("/api/v1/archive/purge", axum_post(purge_archive))
7481            .with_state(state);
7482        let resp = app
7483            .oneshot(
7484                axum::http::Request::builder()
7485                    .uri("/api/v1/archive/purge?older_than_days=-1")
7486                    .method("POST")
7487                    .body(Body::empty())
7488                    .unwrap(),
7489            )
7490            .await
7491            .unwrap();
7492        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
7493    }
7494
7495    #[tokio::test]
7496    async fn http_purge_archive_no_days_purges_unconditional() {
7497        // Omit older_than_days entirely → DELETE every archive row.
7498        let state = test_state();
7499        let id = insert_test_memory(&state, "purge-all", "x").await;
7500        {
7501            let lock = state.lock().await;
7502            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7503        }
7504        let app = Router::new()
7505            .route("/api/v1/archive/purge", axum_post(purge_archive))
7506            .with_state(state.clone());
7507        let resp = app
7508            .oneshot(
7509                axum::http::Request::builder()
7510                    .uri("/api/v1/archive/purge")
7511                    .method("POST")
7512                    .body(Body::empty())
7513                    .unwrap(),
7514            )
7515            .await
7516            .unwrap();
7517        assert_eq!(resp.status(), StatusCode::OK);
7518        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7519            .await
7520            .unwrap();
7521        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7522        assert_eq!(v["purged"], 1);
7523    }
7524
7525    #[tokio::test]
7526    async fn http_archive_stats_reports_per_namespace_counts() {
7527        let state = test_state();
7528        let id_a = insert_test_memory(&state, "stats-a", "a").await;
7529        let id_b = insert_test_memory(&state, "stats-b", "b").await;
7530        {
7531            let lock = state.lock().await;
7532            db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
7533            db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
7534        }
7535        let app = Router::new()
7536            .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
7537            .with_state(state);
7538        let resp = app
7539            .oneshot(
7540                axum::http::Request::builder()
7541                    .uri("/api/v1/archive/stats")
7542                    .body(Body::empty())
7543                    .unwrap(),
7544            )
7545            .await
7546            .unwrap();
7547        assert_eq!(resp.status(), StatusCode::OK);
7548        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7549            .await
7550            .unwrap();
7551        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7552        assert_eq!(v["archived_total"], 2);
7553        assert_eq!(v["by_namespace"].as_array().unwrap().len(), 2);
7554    }
7555
7556    #[tokio::test]
7557    async fn http_archive_by_ids_rejects_oversized_batch() {
7558        // bulk size limit defends the handler.
7559        let state = test_state();
7560        let app = Router::new()
7561            .route("/api/v1/archive", axum_post(archive_by_ids))
7562            .with_state(test_app_state(state));
7563        let big_ids: Vec<String> = (0..=MAX_BULK_SIZE)
7564            .map(|_| Uuid::new_v4().to_string())
7565            .collect();
7566        let body = serde_json::json!({"ids": big_ids});
7567        let resp = app
7568            .oneshot(
7569                axum::http::Request::builder()
7570                    .uri("/api/v1/archive")
7571                    .method("POST")
7572                    .header("content-type", "application/json")
7573                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7574                    .unwrap(),
7575            )
7576            .await
7577            .unwrap();
7578        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7579        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7580            .await
7581            .unwrap();
7582        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7583        assert!(v["error"].as_str().unwrap().contains("archive limited"));
7584    }
7585
7586    #[tokio::test]
7587    async fn http_archive_by_ids_rejects_invalid_id_in_batch() {
7588        let state = test_state();
7589        let app = Router::new()
7590            .route("/api/v1/archive", axum_post(archive_by_ids))
7591            .with_state(test_app_state(state));
7592        // Whitespace-only id triggers validate_id's empty check.
7593        let body = serde_json::json!({"ids": ["   "]});
7594        let resp = app
7595            .oneshot(
7596                axum::http::Request::builder()
7597                    .uri("/api/v1/archive")
7598                    .method("POST")
7599                    .header("content-type", "application/json")
7600                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7601                    .unwrap(),
7602            )
7603            .await
7604            .unwrap();
7605        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7606        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7607            .await
7608            .unwrap();
7609        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7610        assert!(v["error"].as_str().unwrap().contains("invalid id"));
7611    }
7612
7613    #[tokio::test]
7614    async fn http_archive_by_ids_all_missing() {
7615        // Every supplied id is missing locally → 200 with archived=[]
7616        // and missing=[…all…]. Confirms the “no live row” path fires
7617        // for every id without short-circuiting.
7618        let state = test_state();
7619        let app = Router::new()
7620            .route("/api/v1/archive", axum_post(archive_by_ids))
7621            .with_state(test_app_state(state));
7622        let ids: Vec<String> = (0..3).map(|_| Uuid::new_v4().to_string()).collect();
7623        let body = serde_json::json!({"ids": ids});
7624        let resp = app
7625            .oneshot(
7626                axum::http::Request::builder()
7627                    .uri("/api/v1/archive")
7628                    .method("POST")
7629                    .header("content-type", "application/json")
7630                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
7631                    .unwrap(),
7632            )
7633            .await
7634            .unwrap();
7635        assert_eq!(resp.status(), StatusCode::OK);
7636        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7637            .await
7638            .unwrap();
7639        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7640        assert_eq!(v["count"], 0);
7641        assert_eq!(v["archived"].as_array().unwrap().len(), 0);
7642        assert_eq!(v["missing"].as_array().unwrap().len(), 3);
7643    }
7644
7645    // ---- Bulk-create partial success ----
7646
7647    #[tokio::test]
7648    async fn http_bulk_create_oversized_batch_rejected() {
7649        let state = test_state();
7650        let app = Router::new()
7651            .route("/api/v1/memories/bulk", axum_post(bulk_create))
7652            .with_state(test_app_state(state));
7653        let bodies: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
7654            .map(|i| {
7655                serde_json::json!({
7656                    "tier": "long",
7657                    "namespace": "bulk-overflow",
7658                    "title": format!("t-{i}"),
7659                    "content": "c",
7660                    "tags": [],
7661                    "priority": 5,
7662                    "confidence": 1.0,
7663                    "source": "api",
7664                    "metadata": {}
7665                })
7666            })
7667            .collect();
7668        let resp = app
7669            .oneshot(
7670                axum::http::Request::builder()
7671                    .uri("/api/v1/memories/bulk")
7672                    .method("POST")
7673                    .header("content-type", "application/json")
7674                    .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7675                    .unwrap(),
7676            )
7677            .await
7678            .unwrap();
7679        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7680    }
7681
7682    #[tokio::test]
7683    async fn http_bulk_create_partial_success_collects_errors() {
7684        // One row passes, one row fails validation (empty title). The
7685        // handler must commit the good row, push the bad row's reason
7686        // onto `errors`, and return 200 with `created=1`.
7687        let state = test_state();
7688        let app = Router::new()
7689            .route("/api/v1/memories/bulk", axum_post(bulk_create))
7690            .with_state(test_app_state(state.clone()));
7691        let bodies = serde_json::json!([
7692            {
7693                "tier": "long",
7694                "namespace": "bulk-mixed",
7695                "title": "good row",
7696                "content": "ok",
7697                "tags": [],
7698                "priority": 5,
7699                "confidence": 1.0,
7700                "source": "api",
7701                "metadata": {}
7702            },
7703            {
7704                "tier": "long",
7705                "namespace": "bulk-mixed",
7706                "title": "",
7707                "content": "bad: empty title",
7708                "tags": [],
7709                "priority": 5,
7710                "confidence": 1.0,
7711                "source": "api",
7712                "metadata": {}
7713            }
7714        ]);
7715        let resp = app
7716            .oneshot(
7717                axum::http::Request::builder()
7718                    .uri("/api/v1/memories/bulk")
7719                    .method("POST")
7720                    .header("content-type", "application/json")
7721                    .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7722                    .unwrap(),
7723            )
7724            .await
7725            .unwrap();
7726        assert_eq!(resp.status(), StatusCode::OK);
7727        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7728            .await
7729            .unwrap();
7730        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7731        assert_eq!(v["created"], 1);
7732        assert_eq!(v["errors"].as_array().unwrap().len(), 1);
7733
7734        // The good row must be visible in the DB.
7735        let lock = state.lock().await;
7736        let rows = db::list(
7737            &lock.0,
7738            Some("bulk-mixed"),
7739            None,
7740            10,
7741            0,
7742            None,
7743            None,
7744            None,
7745            None,
7746            None,
7747        )
7748        .unwrap();
7749        assert_eq!(rows.len(), 1);
7750        assert_eq!(rows[0].title, "good row");
7751    }
7752
7753    #[tokio::test]
7754    async fn http_bulk_create_empty_body_succeeds_with_zero_created() {
7755        let state = test_state();
7756        let app = Router::new()
7757            .route("/api/v1/memories/bulk", axum_post(bulk_create))
7758            .with_state(test_app_state(state));
7759        let bodies: Vec<serde_json::Value> = vec![];
7760        let resp = app
7761            .oneshot(
7762                axum::http::Request::builder()
7763                    .uri("/api/v1/memories/bulk")
7764                    .method("POST")
7765                    .header("content-type", "application/json")
7766                    .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7767                    .unwrap(),
7768            )
7769            .await
7770            .unwrap();
7771        assert_eq!(resp.status(), StatusCode::OK);
7772        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7773            .await
7774            .unwrap();
7775        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7776        assert_eq!(v["created"], 0);
7777        assert!(v["errors"].as_array().unwrap().is_empty());
7778    }
7779
7780    // ---- Pending workflow edge cases ----
7781
7782    #[tokio::test]
7783    async fn http_list_pending_empty_returns_zero_count() {
7784        let state = test_state();
7785        let app = Router::new()
7786            .route("/api/v1/pending", axum::routing::get(list_pending))
7787            .with_state(state);
7788        let resp = app
7789            .oneshot(
7790                axum::http::Request::builder()
7791                    .uri("/api/v1/pending")
7792                    .body(Body::empty())
7793                    .unwrap(),
7794            )
7795            .await
7796            .unwrap();
7797        assert_eq!(resp.status(), StatusCode::OK);
7798        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7799            .await
7800            .unwrap();
7801        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7802        assert_eq!(v["count"], 0);
7803    }
7804
7805    #[tokio::test]
7806    async fn http_list_pending_with_status_filter() {
7807        let state = test_state();
7808        let app = Router::new()
7809            .route("/api/v1/pending", axum::routing::get(list_pending))
7810            .with_state(state);
7811        // Status=approved gets the SQL filter path. Empty result is fine.
7812        let resp = app
7813            .oneshot(
7814                axum::http::Request::builder()
7815                    .uri("/api/v1/pending?status=approved&limit=5")
7816                    .body(Body::empty())
7817                    .unwrap(),
7818            )
7819            .await
7820            .unwrap();
7821        assert_eq!(resp.status(), StatusCode::OK);
7822    }
7823
7824    #[tokio::test]
7825    async fn http_approve_pending_unknown_id_returns_403_or_500() {
7826        // approve_pending validates the id format, then attempts approval.
7827        // An unknown but-valid uuid surfaces as 403 (rejected) or 500
7828        // (DB row missing). Either is acceptable — both confirm the
7829        // post-validation handler arms execute.
7830        let state = test_state();
7831        let app = Router::new()
7832            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
7833            .with_state(test_app_state(state));
7834        let unknown = Uuid::new_v4().to_string();
7835        let resp = app
7836            .oneshot(
7837                axum::http::Request::builder()
7838                    .uri(format!("/api/v1/pending/{unknown}/approve"))
7839                    .method("POST")
7840                    .header("x-agent-id", "alice")
7841                    .body(Body::empty())
7842                    .unwrap(),
7843            )
7844            .await
7845            .unwrap();
7846        assert!(
7847            resp.status() == StatusCode::FORBIDDEN
7848                || resp.status() == StatusCode::INTERNAL_SERVER_ERROR
7849                || resp.status() == StatusCode::ACCEPTED,
7850            "unexpected status {}",
7851            resp.status()
7852        );
7853    }
7854
7855    #[tokio::test]
7856    async fn http_approve_pending_rejects_invalid_agent_id() {
7857        // Passing a malformed X-Agent-Id (containing a space) triggers
7858        // resolve_http_agent_id's validation and yields a 400.
7859        let state = test_state();
7860        let app = Router::new()
7861            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
7862            .with_state(test_app_state(state));
7863        let id = Uuid::new_v4().to_string();
7864        let resp = app
7865            .oneshot(
7866                axum::http::Request::builder()
7867                    .uri(format!("/api/v1/pending/{id}/approve"))
7868                    .method("POST")
7869                    .header("x-agent-id", "bad agent")
7870                    .body(Body::empty())
7871                    .unwrap(),
7872            )
7873            .await
7874            .unwrap();
7875        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7876    }
7877
7878    #[tokio::test]
7879    async fn http_reject_pending_unknown_id_returns_404() {
7880        let state = test_state();
7881        let app = Router::new()
7882            .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
7883            .with_state(test_app_state(state));
7884        let unknown = Uuid::new_v4().to_string();
7885        let resp = app
7886            .oneshot(
7887                axum::http::Request::builder()
7888                    .uri(format!("/api/v1/pending/{unknown}/reject"))
7889                    .method("POST")
7890                    .header("x-agent-id", "alice")
7891                    .body(Body::empty())
7892                    .unwrap(),
7893            )
7894            .await
7895            .unwrap();
7896        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7897    }
7898
7899    #[tokio::test]
7900    async fn http_reject_pending_rejects_invalid_agent_id() {
7901        let state = test_state();
7902        let app = Router::new()
7903            .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
7904            .with_state(test_app_state(state));
7905        let id = Uuid::new_v4().to_string();
7906        let resp = app
7907            .oneshot(
7908                axum::http::Request::builder()
7909                    .uri(format!("/api/v1/pending/{id}/reject"))
7910                    .method("POST")
7911                    .header("x-agent-id", "bad agent")
7912                    .body(Body::empty())
7913                    .unwrap(),
7914            )
7915            .await
7916            .unwrap();
7917        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7918    }
7919
7920    // ---- Search edge cases ----
7921
7922    #[tokio::test]
7923    async fn http_search_rejects_blank_query() {
7924        let state = test_state();
7925        let app = Router::new()
7926            .route(
7927                "/api/v1/memories/search",
7928                axum::routing::get(search_memories),
7929            )
7930            .with_state(state);
7931        let resp = app
7932            .oneshot(
7933                axum::http::Request::builder()
7934                    .uri("/api/v1/memories/search?q=%20%20%20") // whitespace only
7935                    .body(Body::empty())
7936                    .unwrap(),
7937            )
7938            .await
7939            .unwrap();
7940        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7941    }
7942
7943    #[tokio::test]
7944    async fn http_search_long_query_succeeds() {
7945        // Boundary: very long query string. Must not crash; either
7946        // returns 200 with empty results or a specific 400 from validation.
7947        let state = test_state();
7948        let app = Router::new()
7949            .route(
7950                "/api/v1/memories/search",
7951                axum::routing::get(search_memories),
7952            )
7953            .with_state(state);
7954        let q = "a".repeat(2_000);
7955        let resp = app
7956            .oneshot(
7957                axum::http::Request::builder()
7958                    .uri(format!("/api/v1/memories/search?q={q}"))
7959                    .body(Body::empty())
7960                    .unwrap(),
7961            )
7962            .await
7963            .unwrap();
7964        assert!(
7965            resp.status() == StatusCode::OK
7966                || resp.status() == StatusCode::BAD_REQUEST
7967                || resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
7968            "unexpected status {}",
7969            resp.status()
7970        );
7971    }
7972
7973    #[tokio::test]
7974    async fn http_search_normal_query_returns_results_array() {
7975        // Sanity smoke for the search happy path post-validation. Empty
7976        // DB → 200 with results=[].
7977        let state = test_state();
7978        let app = Router::new()
7979            .route(
7980                "/api/v1/memories/search",
7981                axum::routing::get(search_memories),
7982            )
7983            .with_state(state);
7984        let resp = app
7985            .oneshot(
7986                axum::http::Request::builder()
7987                    .uri("/api/v1/memories/search?q=hello")
7988                    .body(Body::empty())
7989                    .unwrap(),
7990            )
7991            .await
7992            .unwrap();
7993        assert_eq!(resp.status(), StatusCode::OK);
7994        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7995            .await
7996            .unwrap();
7997        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7998        assert!(v["results"].is_array());
7999        assert_eq!(v["query"], "hello");
8000    }
8001
8002    #[tokio::test]
8003    async fn http_search_invalid_agent_id_filter_rejected() {
8004        let state = test_state();
8005        let app = Router::new()
8006            .route(
8007                "/api/v1/memories/search",
8008                axum::routing::get(search_memories),
8009            )
8010            .with_state(state);
8011        // `bad agent` (decoded with %20 space) — agent_id must reject spaces.
8012        let resp = app
8013            .oneshot(
8014                axum::http::Request::builder()
8015                    .uri("/api/v1/memories/search?q=test&agent_id=bad%20agent")
8016                    .body(Body::empty())
8017                    .unwrap(),
8018            )
8019            .await
8020            .unwrap();
8021        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8022    }
8023
8024    // ---- Recall edge cases ----
8025
8026    #[tokio::test]
8027    async fn http_recall_get_rejects_blank_context() {
8028        let state = test_state();
8029        let app = Router::new()
8030            .route(
8031                "/api/v1/memories/recall",
8032                axum::routing::get(recall_memories_get),
8033            )
8034            .with_state(test_app_state(state));
8035        let resp = app
8036            .oneshot(
8037                axum::http::Request::builder()
8038                    .uri("/api/v1/memories/recall?context=%20")
8039                    .body(Body::empty())
8040                    .unwrap(),
8041            )
8042            .await
8043            .unwrap();
8044        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8045    }
8046
8047    #[tokio::test]
8048    async fn http_recall_get_zero_budget_tokens_returns_empty() {
8049        // Phase P6 (R1): budget_tokens=0 is now a valid request — see
8050        // recall_post_zero_budget_tokens_returns_empty for full
8051        // semantics. Returns 200 with an empty memories array.
8052        let state = test_state();
8053        let app = Router::new()
8054            .route(
8055                "/api/v1/memories/recall",
8056                axum::routing::get(recall_memories_get),
8057            )
8058            .with_state(test_app_state(state));
8059        let resp = app
8060            .oneshot(
8061                axum::http::Request::builder()
8062                    .uri("/api/v1/memories/recall?context=hi&budget_tokens=0")
8063                    .body(Body::empty())
8064                    .unwrap(),
8065            )
8066            .await
8067            .unwrap();
8068        assert_eq!(resp.status(), StatusCode::OK);
8069        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8070            .await
8071            .unwrap();
8072        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8073        assert_eq!(v["count"], 0);
8074        assert_eq!(v["budget_tokens"], 0);
8075        assert_eq!(v["meta"]["budget_overflow"], false);
8076    }
8077
8078    #[tokio::test]
8079    async fn http_recall_post_rejects_blank_context() {
8080        let state = test_state();
8081        let app = Router::new()
8082            .route("/api/v1/memories/recall", axum_post(recall_memories_post))
8083            .with_state(test_app_state(state));
8084        let body = serde_json::json!({"context": "   "});
8085        let resp = app
8086            .oneshot(
8087                axum::http::Request::builder()
8088                    .uri("/api/v1/memories/recall")
8089                    .method("POST")
8090                    .header("content-type", "application/json")
8091                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8092                    .unwrap(),
8093            )
8094            .await
8095            .unwrap();
8096        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8097    }
8098
8099    #[tokio::test]
8100    async fn http_recall_post_keyword_mode_returns_mode_field() {
8101        // Without an embedder, recall_response must fall through to
8102        // keyword mode and surface that fact on the response.
8103        let state = test_state();
8104        let _id = insert_test_memory(&state, "recall-mode", "the title").await;
8105        let app = Router::new()
8106            .route("/api/v1/memories/recall", axum_post(recall_memories_post))
8107            .with_state(test_app_state(state));
8108        let body = serde_json::json!({"context": "title", "namespace": "recall-mode"});
8109        let resp = app
8110            .oneshot(
8111                axum::http::Request::builder()
8112                    .uri("/api/v1/memories/recall")
8113                    .method("POST")
8114                    .header("content-type", "application/json")
8115                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8116                    .unwrap(),
8117            )
8118            .await
8119            .unwrap();
8120        assert_eq!(resp.status(), StatusCode::OK);
8121        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8122            .await
8123            .unwrap();
8124        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8125        assert_eq!(v["mode"], "keyword");
8126    }
8127
8128    // ---- Sync / streaming-like paths ----
8129
8130    #[tokio::test]
8131    async fn http_sync_since_empty_db_returns_zero_count() {
8132        let state = test_state();
8133        let app = Router::new()
8134            .route("/api/v1/sync/since", axum::routing::get(sync_since))
8135            .with_state(state);
8136        let resp = app
8137            .oneshot(
8138                axum::http::Request::builder()
8139                    .uri("/api/v1/sync/since?since=2000-01-01T00:00:00Z&limit=10")
8140                    .body(Body::empty())
8141                    .unwrap(),
8142            )
8143            .await
8144            .unwrap();
8145        assert_eq!(resp.status(), StatusCode::OK);
8146        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8147            .await
8148            .unwrap();
8149        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8150        assert_eq!(v["count"], 0);
8151        assert!(v["earliest_updated_at"].is_null());
8152        assert!(v["latest_updated_at"].is_null());
8153    }
8154
8155    #[tokio::test]
8156    async fn http_sync_since_clamps_oversized_limit() {
8157        let state = test_state();
8158        let app = Router::new()
8159            .route("/api/v1/sync/since", axum::routing::get(sync_since))
8160            .with_state(state);
8161        let resp = app
8162            .oneshot(
8163                axum::http::Request::builder()
8164                    .uri("/api/v1/sync/since?limit=999999")
8165                    .body(Body::empty())
8166                    .unwrap(),
8167            )
8168            .await
8169            .unwrap();
8170        assert_eq!(resp.status(), StatusCode::OK);
8171        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8172            .await
8173            .unwrap();
8174        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8175        // Limit must be clamped to <= 10_000.
8176        assert!(v["limit"].as_u64().unwrap() <= 10_000);
8177    }
8178
8179    #[tokio::test]
8180    async fn http_sync_since_empty_since_string_treated_as_full_snapshot() {
8181        // since="" must NOT be parsed as RFC 3339. The handler short-circuits
8182        // empty strings to "no since filter" and returns a full snapshot.
8183        let state = test_state();
8184        let _id = insert_test_memory(&state, "sync-empty", "row").await;
8185        let app = Router::new()
8186            .route("/api/v1/sync/since", axum::routing::get(sync_since))
8187            .with_state(state);
8188        let resp = app
8189            .oneshot(
8190                axum::http::Request::builder()
8191                    .uri("/api/v1/sync/since?since=")
8192                    .body(Body::empty())
8193                    .unwrap(),
8194            )
8195            .await
8196            .unwrap();
8197        assert_eq!(resp.status(), StatusCode::OK);
8198    }
8199
8200    #[tokio::test]
8201    async fn http_sync_since_records_peer_via_observe() {
8202        // Hitting sync_since with a `peer=` param and an X-Agent-Id header
8203        // exercises the side-effect sync_state_observe write path.
8204        let state = test_state();
8205        let _id = insert_test_memory(&state, "sync-peer", "row").await;
8206        let app = Router::new()
8207            .route("/api/v1/sync/since", axum::routing::get(sync_since))
8208            .with_state(state.clone());
8209        let resp = app
8210            .oneshot(
8211                axum::http::Request::builder()
8212                    .uri("/api/v1/sync/since?peer=peer-x")
8213                    .header("x-agent-id", "alice")
8214                    .body(Body::empty())
8215                    .unwrap(),
8216            )
8217            .await
8218            .unwrap();
8219        assert_eq!(resp.status(), StatusCode::OK);
8220    }
8221
8222    // ---- Capabilities + session_start + taxonomy ----
8223
8224    #[tokio::test]
8225    async fn http_capabilities_returns_features() {
8226        let state = test_state();
8227        let app = Router::new()
8228            .route("/api/v1/capabilities", axum::routing::get(get_capabilities))
8229            .with_state(test_app_state(state));
8230        let resp = app
8231            .oneshot(
8232                axum::http::Request::builder()
8233                    .uri("/api/v1/capabilities")
8234                    .body(Body::empty())
8235                    .unwrap(),
8236            )
8237            .await
8238            .unwrap();
8239        assert_eq!(resp.status(), StatusCode::OK);
8240        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8241            .await
8242            .unwrap();
8243        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8244        // embedder_loaded must be false in this AppState — we wired
8245        // Arc::new(None).
8246        assert_eq!(v["features"]["embedder_loaded"], false);
8247    }
8248
8249    #[tokio::test]
8250    async fn http_session_start_rejects_invalid_agent_id() {
8251        let state = test_state();
8252        let app = Router::new()
8253            .route("/api/v1/session/start", axum_post(session_start))
8254            .with_state(state);
8255        let body = serde_json::json!({"agent_id": "bad agent id with spaces"});
8256        let resp = app
8257            .oneshot(
8258                axum::http::Request::builder()
8259                    .uri("/api/v1/session/start")
8260                    .method("POST")
8261                    .header("content-type", "application/json")
8262                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8263                    .unwrap(),
8264            )
8265            .await
8266            .unwrap();
8267        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8268    }
8269
8270    #[tokio::test]
8271    async fn http_session_start_stamps_session_id() {
8272        let state = test_state();
8273        let app = Router::new()
8274            .route("/api/v1/session/start", axum_post(session_start))
8275            .with_state(state);
8276        let body = serde_json::json!({});
8277        let resp = app
8278            .oneshot(
8279                axum::http::Request::builder()
8280                    .uri("/api/v1/session/start")
8281                    .method("POST")
8282                    .header("content-type", "application/json")
8283                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8284                    .unwrap(),
8285            )
8286            .await
8287            .unwrap();
8288        assert_eq!(resp.status(), StatusCode::OK);
8289        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8290            .await
8291            .unwrap();
8292        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8293        assert!(v["session_id"].as_str().is_some());
8294    }
8295
8296    #[tokio::test]
8297    async fn http_get_taxonomy_rejects_invalid_prefix() {
8298        // namespace validation rejects spaces — `bad%20prefix` decodes
8299        // to `bad prefix`, which fails validate_namespace.
8300        let state = test_state();
8301        let app = Router::new()
8302            .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
8303            .with_state(state);
8304        let resp = app
8305            .oneshot(
8306                axum::http::Request::builder()
8307                    .uri("/api/v1/taxonomy?prefix=bad%20prefix")
8308                    .body(Body::empty())
8309                    .unwrap(),
8310            )
8311            .await
8312            .unwrap();
8313        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8314    }
8315
8316    #[tokio::test]
8317    async fn http_get_taxonomy_clamps_depth_and_limit() {
8318        let state = test_state();
8319        let app = Router::new()
8320            .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
8321            .with_state(state);
8322        let resp = app
8323            .oneshot(
8324                axum::http::Request::builder()
8325                    .uri("/api/v1/taxonomy?depth=1000&limit=999999")
8326                    .body(Body::empty())
8327                    .unwrap(),
8328            )
8329            .await
8330            .unwrap();
8331        assert_eq!(resp.status(), StatusCode::OK);
8332    }
8333
8334    // ---- list_subscriptions ----
8335
8336    #[tokio::test]
8337    async fn http_list_subscriptions_empty_returns_zero() {
8338        let state = test_state();
8339        let app = Router::new()
8340            .route(
8341                "/api/v1/subscriptions",
8342                axum::routing::get(list_subscriptions),
8343            )
8344            .with_state(state);
8345        let resp = app
8346            .oneshot(
8347                axum::http::Request::builder()
8348                    .uri("/api/v1/subscriptions")
8349                    .body(Body::empty())
8350                    .unwrap(),
8351            )
8352            .await
8353            .unwrap();
8354        assert_eq!(resp.status(), StatusCode::OK);
8355        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8356            .await
8357            .unwrap();
8358        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8359        assert_eq!(v["count"], 0);
8360        assert!(v["subscriptions"].as_array().unwrap().is_empty());
8361    }
8362
8363    #[tokio::test]
8364    async fn http_list_subscriptions_filters_by_agent_id() {
8365        // No subscriptions exist yet — filter still works (returns 0).
8366        // Confirms the agent_id filter branch executes.
8367        let state = test_state();
8368        let app = Router::new()
8369            .route(
8370                "/api/v1/subscriptions",
8371                axum::routing::get(list_subscriptions),
8372            )
8373            .with_state(state);
8374        let resp = app
8375            .oneshot(
8376                axum::http::Request::builder()
8377                    .uri("/api/v1/subscriptions?agent_id=alice")
8378                    .body(Body::empty())
8379                    .unwrap(),
8380            )
8381            .await
8382            .unwrap();
8383        assert_eq!(resp.status(), StatusCode::OK);
8384    }
8385
8386    // ---- get_inbox ----
8387
8388    #[tokio::test]
8389    async fn http_get_inbox_with_x_agent_id_header() {
8390        let state = test_state();
8391        let app = Router::new()
8392            .route("/api/v1/inbox", axum::routing::get(get_inbox))
8393            .with_state(test_app_state(state));
8394        let resp = app
8395            .oneshot(
8396                axum::http::Request::builder()
8397                    .uri("/api/v1/inbox?unread_only=true&limit=20")
8398                    .header("x-agent-id", "alice")
8399                    .body(Body::empty())
8400                    .unwrap(),
8401            )
8402            .await
8403            .unwrap();
8404        assert_eq!(resp.status(), StatusCode::OK);
8405    }
8406
8407    // -------------------------------------------------------------------
8408    // Wave 3 (Closer T) — targeted unit tests for code paths NOT yet
8409    // covered by Wave 2's smoke + lifecycle + format tests. Each block
8410    // below targets a specific uncovered run located via the pre-coverage
8411    // JSON snapshot. These exercise production code paths in-process
8412    // (federation = None, embedder = None) so the federation-quorum
8413    // branches stay short-circuited and only the local logic under test
8414    // executes.
8415    // -------------------------------------------------------------------
8416
8417    // ---- check_duplicate (handlers.rs ~L1930-2026) ----
8418
8419    #[tokio::test]
8420    async fn http_check_duplicate_rejects_invalid_title() {
8421        let state = test_state();
8422        let app = Router::new()
8423            .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8424            .with_state(test_app_state(state));
8425        // Empty title fails validation.
8426        let body = serde_json::json!({"title": "", "content": "non-empty"});
8427        let resp = app
8428            .oneshot(
8429                axum::http::Request::builder()
8430                    .uri("/api/v1/check_duplicate")
8431                    .method("POST")
8432                    .header("content-type", "application/json")
8433                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8434                    .unwrap(),
8435            )
8436            .await
8437            .unwrap();
8438        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8439    }
8440
8441    #[tokio::test]
8442    async fn http_check_duplicate_rejects_invalid_content() {
8443        let state = test_state();
8444        let app = Router::new()
8445            .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8446            .with_state(test_app_state(state));
8447        // Empty content fails validation.
8448        let body = serde_json::json!({"title": "ok", "content": ""});
8449        let resp = app
8450            .oneshot(
8451                axum::http::Request::builder()
8452                    .uri("/api/v1/check_duplicate")
8453                    .method("POST")
8454                    .header("content-type", "application/json")
8455                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8456                    .unwrap(),
8457            )
8458            .await
8459            .unwrap();
8460        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8461    }
8462
8463    #[tokio::test]
8464    async fn http_check_duplicate_rejects_invalid_namespace() {
8465        let state = test_state();
8466        let app = Router::new()
8467            .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8468            .with_state(test_app_state(state));
8469        // Namespace with disallowed characters fails validation.
8470        let body = serde_json::json!({
8471            "title": "ok",
8472            "content": "ok content",
8473            "namespace": "BAD NAMESPACE WITH SPACES",
8474        });
8475        let resp = app
8476            .oneshot(
8477                axum::http::Request::builder()
8478                    .uri("/api/v1/check_duplicate")
8479                    .method("POST")
8480                    .header("content-type", "application/json")
8481                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8482                    .unwrap(),
8483            )
8484            .await
8485            .unwrap();
8486        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8487    }
8488
8489    #[tokio::test]
8490    async fn http_check_duplicate_503_when_no_embedder() {
8491        // Without an embedder, check_duplicate cannot run (returns 503).
8492        let state = test_state();
8493        let app = Router::new()
8494            .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8495            .with_state(test_app_state(state));
8496        let body = serde_json::json!({"title": "anchor", "content": "some long enough content"});
8497        let resp = app
8498            .oneshot(
8499                axum::http::Request::builder()
8500                    .uri("/api/v1/check_duplicate")
8501                    .method("POST")
8502                    .header("content-type", "application/json")
8503                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8504                    .unwrap(),
8505            )
8506            .await
8507            .unwrap();
8508        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
8509    }
8510
8511    // ---- entity_register / entity_get_by_alias (handlers.rs ~L2058-2205) ----
8512
8513    #[tokio::test]
8514    async fn http_entity_register_creates_then_idempotent_returns_200() {
8515        let state = test_state();
8516        let app = Router::new()
8517            .route("/api/v1/entities", axum_post(entity_register))
8518            .with_state(state.clone());
8519        // First call: 201 CREATED.
8520        let body = serde_json::json!({
8521            "canonical_name": "Acme Corp",
8522            "namespace": "kg-test",
8523            "aliases": ["acme", "Acme"],
8524            "metadata": {"region": "us"},
8525        });
8526        let resp = app
8527            .clone()
8528            .oneshot(
8529                axum::http::Request::builder()
8530                    .uri("/api/v1/entities")
8531                    .method("POST")
8532                    .header("content-type", "application/json")
8533                    .header("x-agent-id", "alice")
8534                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8535                    .unwrap(),
8536            )
8537            .await
8538            .unwrap();
8539        assert_eq!(resp.status(), StatusCode::CREATED);
8540
8541        // Second call with same canonical_name+namespace: 200 OK + created=false.
8542        let resp2 = app
8543            .oneshot(
8544                axum::http::Request::builder()
8545                    .uri("/api/v1/entities")
8546                    .method("POST")
8547                    .header("content-type", "application/json")
8548                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8549                    .unwrap(),
8550            )
8551            .await
8552            .unwrap();
8553        assert_eq!(resp2.status(), StatusCode::OK);
8554    }
8555
8556    #[tokio::test]
8557    async fn http_entity_register_rejects_invalid_canonical_name() {
8558        let state = test_state();
8559        let app = Router::new()
8560            .route("/api/v1/entities", axum_post(entity_register))
8561            .with_state(state);
8562        let body = serde_json::json!({
8563            "canonical_name": "",
8564            "namespace": "kg-test",
8565        });
8566        let resp = app
8567            .oneshot(
8568                axum::http::Request::builder()
8569                    .uri("/api/v1/entities")
8570                    .method("POST")
8571                    .header("content-type", "application/json")
8572                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8573                    .unwrap(),
8574            )
8575            .await
8576            .unwrap();
8577        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8578    }
8579
8580    #[tokio::test]
8581    async fn http_entity_register_rejects_invalid_namespace() {
8582        let state = test_state();
8583        let app = Router::new()
8584            .route("/api/v1/entities", axum_post(entity_register))
8585            .with_state(state);
8586        let body = serde_json::json!({
8587            "canonical_name": "Acme",
8588            "namespace": "BAD NS!",
8589        });
8590        let resp = app
8591            .oneshot(
8592                axum::http::Request::builder()
8593                    .uri("/api/v1/entities")
8594                    .method("POST")
8595                    .header("content-type", "application/json")
8596                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8597                    .unwrap(),
8598            )
8599            .await
8600            .unwrap();
8601        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8602    }
8603
8604    #[tokio::test]
8605    async fn http_entity_register_rejects_invalid_agent_id_header() {
8606        let state = test_state();
8607        let app = Router::new()
8608            .route("/api/v1/entities", axum_post(entity_register))
8609            .with_state(state);
8610        let body = serde_json::json!({
8611            "canonical_name": "Acme",
8612            "namespace": "kg-test",
8613        });
8614        let resp = app
8615            .oneshot(
8616                axum::http::Request::builder()
8617                    .uri("/api/v1/entities")
8618                    .method("POST")
8619                    .header("content-type", "application/json")
8620                    .header("x-agent-id", "BAD AGENT!")
8621                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8622                    .unwrap(),
8623            )
8624            .await
8625            .unwrap();
8626        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8627    }
8628
8629    #[tokio::test]
8630    async fn http_entity_register_collision_with_non_entity_returns_409() {
8631        // Pre-seed a non-entity memory at (namespace, title), then attempt
8632        // entity_register with the same canonical_name+namespace.
8633        let state = test_state();
8634        let now = Utc::now().to_rfc3339();
8635        {
8636            let lock = state.lock().await;
8637            let mem = Memory {
8638                id: Uuid::new_v4().to_string(),
8639                tier: Tier::Long,
8640                namespace: "collide-ns".into(),
8641                title: "Acme Squat".into(),
8642                content: "this is a regular memory".into(),
8643                tags: vec![],
8644                priority: 5,
8645                confidence: 1.0,
8646                source: "test".into(),
8647                access_count: 0,
8648                created_at: now.clone(),
8649                updated_at: now,
8650                last_accessed_at: None,
8651                expires_at: None,
8652                metadata: serde_json::json!({}),
8653            };
8654            db::insert(&lock.0, &mem).unwrap();
8655        }
8656        let app = Router::new()
8657            .route("/api/v1/entities", axum_post(entity_register))
8658            .with_state(state);
8659        let body = serde_json::json!({
8660            "canonical_name": "Acme Squat",
8661            "namespace": "collide-ns",
8662        });
8663        let resp = app
8664            .oneshot(
8665                axum::http::Request::builder()
8666                    .uri("/api/v1/entities")
8667                    .method("POST")
8668                    .header("content-type", "application/json")
8669                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8670                    .unwrap(),
8671            )
8672            .await
8673            .unwrap();
8674        assert_eq!(resp.status(), StatusCode::CONFLICT);
8675    }
8676
8677    #[tokio::test]
8678    async fn http_entity_get_by_alias_blank_alias_rejected() {
8679        let state = test_state();
8680        let app = Router::new()
8681            .route(
8682                "/api/v1/entities/by_alias",
8683                axum::routing::get(entity_get_by_alias),
8684            )
8685            .with_state(state);
8686        let resp = app
8687            .oneshot(
8688                axum::http::Request::builder()
8689                    .uri("/api/v1/entities/by_alias?alias=%20%20")
8690                    .body(Body::empty())
8691                    .unwrap(),
8692            )
8693            .await
8694            .unwrap();
8695        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8696    }
8697
8698    #[tokio::test]
8699    async fn http_entity_get_by_alias_invalid_namespace_rejected() {
8700        let state = test_state();
8701        let app = Router::new()
8702            .route(
8703                "/api/v1/entities/by_alias",
8704                axum::routing::get(entity_get_by_alias),
8705            )
8706            .with_state(state);
8707        let resp = app
8708            .oneshot(
8709                axum::http::Request::builder()
8710                    .uri("/api/v1/entities/by_alias?alias=acme&namespace=BAD%20NS!")
8711                    .body(Body::empty())
8712                    .unwrap(),
8713            )
8714            .await
8715            .unwrap();
8716        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8717    }
8718
8719    #[tokio::test]
8720    async fn http_entity_get_by_alias_returns_found_false_when_unknown() {
8721        let state = test_state();
8722        let app = Router::new()
8723            .route(
8724                "/api/v1/entities/by_alias",
8725                axum::routing::get(entity_get_by_alias),
8726            )
8727            .with_state(state);
8728        let resp = app
8729            .oneshot(
8730                axum::http::Request::builder()
8731                    .uri("/api/v1/entities/by_alias?alias=nonexistent")
8732                    .body(Body::empty())
8733                    .unwrap(),
8734            )
8735            .await
8736            .unwrap();
8737        assert_eq!(resp.status(), StatusCode::OK);
8738        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8739            .await
8740            .unwrap();
8741        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8742        assert_eq!(v["found"], serde_json::json!(false));
8743    }
8744
8745    #[tokio::test]
8746    async fn http_entity_get_by_alias_returns_found_true_after_register() {
8747        // Pre-register an entity, then look it up by alias.
8748        let state = test_state();
8749        {
8750            let lock = state.lock().await;
8751            db::entity_register(
8752                &lock.0,
8753                "Acme Corp",
8754                "kg-lookup",
8755                &["acme".to_string(), "ACME".to_string()],
8756                &serde_json::json!({}),
8757                Some("alice"),
8758            )
8759            .unwrap();
8760        }
8761        let app = Router::new()
8762            .route(
8763                "/api/v1/entities/by_alias",
8764                axum::routing::get(entity_get_by_alias),
8765            )
8766            .with_state(state);
8767        let resp = app
8768            .oneshot(
8769                axum::http::Request::builder()
8770                    .uri("/api/v1/entities/by_alias?alias=acme&namespace=kg-lookup")
8771                    .body(Body::empty())
8772                    .unwrap(),
8773            )
8774            .await
8775            .unwrap();
8776        assert_eq!(resp.status(), StatusCode::OK);
8777        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8778            .await
8779            .unwrap();
8780        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8781        assert_eq!(v["found"], serde_json::json!(true));
8782        assert_eq!(v["canonical_name"], serde_json::json!("Acme Corp"));
8783    }
8784
8785    // ---- kg_timeline (handlers.rs ~L2219-2284) ----
8786
8787    #[tokio::test]
8788    async fn http_kg_timeline_rejects_invalid_source_id() {
8789        let state = test_state();
8790        let app = Router::new()
8791            .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8792            .with_state(state);
8793        // Empty source_id is rejected by validate_id.
8794        let resp = app
8795            .oneshot(
8796                axum::http::Request::builder()
8797                    .uri("/api/v1/kg/timeline?source_id=")
8798                    .body(Body::empty())
8799                    .unwrap(),
8800            )
8801            .await
8802            .unwrap();
8803        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8804    }
8805
8806    #[tokio::test]
8807    async fn http_kg_timeline_rejects_invalid_since() {
8808        let state = test_state();
8809        let app = Router::new()
8810            .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8811            .with_state(state);
8812        let id = Uuid::new_v4().to_string();
8813        let uri = format!("/api/v1/kg/timeline?source_id={id}&since=NOT-A-TIMESTAMP");
8814        let resp = app
8815            .oneshot(
8816                axum::http::Request::builder()
8817                    .uri(&uri)
8818                    .body(Body::empty())
8819                    .unwrap(),
8820            )
8821            .await
8822            .unwrap();
8823        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8824    }
8825
8826    #[tokio::test]
8827    async fn http_kg_timeline_rejects_invalid_until() {
8828        let state = test_state();
8829        let app = Router::new()
8830            .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8831            .with_state(state);
8832        let id = Uuid::new_v4().to_string();
8833        let uri = format!("/api/v1/kg/timeline?source_id={id}&until=garbage");
8834        let resp = app
8835            .oneshot(
8836                axum::http::Request::builder()
8837                    .uri(&uri)
8838                    .body(Body::empty())
8839                    .unwrap(),
8840            )
8841            .await
8842            .unwrap();
8843        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8844    }
8845
8846    #[tokio::test]
8847    async fn http_kg_timeline_returns_empty_for_unlinked_source() {
8848        // Valid source_id with no outbound links → 200 + count=0.
8849        let state = test_state();
8850        let id = {
8851            let lock = state.lock().await;
8852            let now = Utc::now().to_rfc3339();
8853            let mem = Memory {
8854                id: Uuid::new_v4().to_string(),
8855                tier: Tier::Long,
8856                namespace: "kg-tl".into(),
8857                title: "anchor".into(),
8858                content: "anchor body".into(),
8859                tags: vec![],
8860                priority: 5,
8861                confidence: 1.0,
8862                source: "test".into(),
8863                access_count: 0,
8864                created_at: now.clone(),
8865                updated_at: now,
8866                last_accessed_at: None,
8867                expires_at: None,
8868                metadata: serde_json::json!({}),
8869            };
8870            db::insert(&lock.0, &mem).unwrap()
8871        };
8872        let app = Router::new()
8873            .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8874            .with_state(state);
8875        let uri = format!("/api/v1/kg/timeline?source_id={id}");
8876        let resp = app
8877            .oneshot(
8878                axum::http::Request::builder()
8879                    .uri(&uri)
8880                    .body(Body::empty())
8881                    .unwrap(),
8882            )
8883            .await
8884            .unwrap();
8885        assert_eq!(resp.status(), StatusCode::OK);
8886        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8887            .await
8888            .unwrap();
8889        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8890        assert_eq!(v["count"], serde_json::json!(0));
8891        assert!(v["events"].is_array());
8892    }
8893
8894    // ---- kg_invalidate (handlers.rs ~L2300-2365) ----
8895
8896    #[tokio::test]
8897    async fn http_kg_invalidate_rejects_invalid_link() {
8898        let state = test_state();
8899        let app = Router::new()
8900            .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8901            .with_state(state);
8902        // Self-link: source_id == target_id → validate_link rejects.
8903        let body = serde_json::json!({
8904            "source_id": "11111111-1111-4111-8111-111111111111",
8905            "target_id": "11111111-1111-4111-8111-111111111111",
8906            "relation": "related_to",
8907        });
8908        let resp = app
8909            .oneshot(
8910                axum::http::Request::builder()
8911                    .uri("/api/v1/kg/invalidate")
8912                    .method("POST")
8913                    .header("content-type", "application/json")
8914                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8915                    .unwrap(),
8916            )
8917            .await
8918            .unwrap();
8919        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8920    }
8921
8922    #[tokio::test]
8923    async fn http_kg_invalidate_rejects_invalid_valid_until() {
8924        let state = test_state();
8925        let app = Router::new()
8926            .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8927            .with_state(state);
8928        let body = serde_json::json!({
8929            "source_id": "11111111-1111-4111-8111-111111111111",
8930            "target_id": "22222222-2222-4222-8222-222222222222",
8931            "relation": "related_to",
8932            "valid_until": "garbage",
8933        });
8934        let resp = app
8935            .oneshot(
8936                axum::http::Request::builder()
8937                    .uri("/api/v1/kg/invalidate")
8938                    .method("POST")
8939                    .header("content-type", "application/json")
8940                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8941                    .unwrap(),
8942            )
8943            .await
8944            .unwrap();
8945        // Bad valid_until is the second validation gate; the (UUID, UUID,
8946        // related_to) link itself is well-formed.
8947        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8948    }
8949
8950    #[tokio::test]
8951    async fn http_kg_invalidate_404_when_link_missing() {
8952        let state = test_state();
8953        let app = Router::new()
8954            .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8955            .with_state(state);
8956        let body = serde_json::json!({
8957            "source_id": "11111111-1111-4111-8111-111111111111",
8958            "target_id": "22222222-2222-4222-8222-222222222222",
8959            "relation": "related_to",
8960        });
8961        let resp = app
8962            .oneshot(
8963                axum::http::Request::builder()
8964                    .uri("/api/v1/kg/invalidate")
8965                    .method("POST")
8966                    .header("content-type", "application/json")
8967                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
8968                    .unwrap(),
8969            )
8970            .await
8971            .unwrap();
8972        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8973    }
8974
8975    #[tokio::test]
8976    async fn http_kg_invalidate_marks_link_as_invalidated() {
8977        // Pre-seed two memories + an outbound link, then invalidate.
8978        let state = test_state();
8979        let (a_id, b_id) = {
8980            let lock = state.lock().await;
8981            let now = Utc::now().to_rfc3339();
8982            let mk = |title: &str| Memory {
8983                id: Uuid::new_v4().to_string(),
8984                tier: Tier::Long,
8985                namespace: "kg-inv".into(),
8986                title: title.into(),
8987                content: format!("{title} body"),
8988                tags: vec![],
8989                priority: 5,
8990                confidence: 1.0,
8991                source: "test".into(),
8992                access_count: 0,
8993                created_at: now.clone(),
8994                updated_at: now.clone(),
8995                last_accessed_at: None,
8996                expires_at: None,
8997                metadata: serde_json::json!({}),
8998            };
8999            let a = db::insert(&lock.0, &mk("source-a")).unwrap();
9000            let b = db::insert(&lock.0, &mk("target-b")).unwrap();
9001            db::create_link(&lock.0, &a, &b, "related_to").unwrap();
9002            (a, b)
9003        };
9004        let app = Router::new()
9005            .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
9006            .with_state(state);
9007        let body = serde_json::json!({
9008            "source_id": a_id,
9009            "target_id": b_id,
9010            "relation": "related_to",
9011        });
9012        let resp = app
9013            .oneshot(
9014                axum::http::Request::builder()
9015                    .uri("/api/v1/kg/invalidate")
9016                    .method("POST")
9017                    .header("content-type", "application/json")
9018                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9019                    .unwrap(),
9020            )
9021            .await
9022            .unwrap();
9023        assert_eq!(resp.status(), StatusCode::OK);
9024        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9025            .await
9026            .unwrap();
9027        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9028        assert_eq!(v["found"], serde_json::json!(true));
9029    }
9030
9031    // ---- kg_query (handlers.rs ~L2387-2484) ----
9032
9033    #[tokio::test]
9034    async fn http_kg_query_rejects_invalid_source_id() {
9035        let state = test_state();
9036        let app = Router::new()
9037            .route("/api/v1/kg/query", axum_post(kg_query))
9038            .with_state(state);
9039        // Empty source_id is rejected by validate_id.
9040        let body = serde_json::json!({"source_id": ""});
9041        let resp = app
9042            .oneshot(
9043                axum::http::Request::builder()
9044                    .uri("/api/v1/kg/query")
9045                    .method("POST")
9046                    .header("content-type", "application/json")
9047                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9048                    .unwrap(),
9049            )
9050            .await
9051            .unwrap();
9052        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9053    }
9054
9055    #[tokio::test]
9056    async fn http_kg_query_rejects_invalid_valid_at() {
9057        let state = test_state();
9058        let app = Router::new()
9059            .route("/api/v1/kg/query", axum_post(kg_query))
9060            .with_state(state);
9061        let body = serde_json::json!({
9062            "source_id": "11111111-1111-4111-8111-111111111111",
9063            "valid_at": "not-a-timestamp",
9064        });
9065        let resp = app
9066            .oneshot(
9067                axum::http::Request::builder()
9068                    .uri("/api/v1/kg/query")
9069                    .method("POST")
9070                    .header("content-type", "application/json")
9071                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9072                    .unwrap(),
9073            )
9074            .await
9075            .unwrap();
9076        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9077    }
9078
9079    #[tokio::test]
9080    async fn http_kg_query_rejects_invalid_allowed_agent() {
9081        let state = test_state();
9082        let app = Router::new()
9083            .route("/api/v1/kg/query", axum_post(kg_query))
9084            .with_state(state);
9085        let body = serde_json::json!({
9086            "source_id": "11111111-1111-4111-8111-111111111111",
9087            "allowed_agents": ["BAD AGENT!"],
9088        });
9089        let resp = app
9090            .oneshot(
9091                axum::http::Request::builder()
9092                    .uri("/api/v1/kg/query")
9093                    .method("POST")
9094                    .header("content-type", "application/json")
9095                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9096                    .unwrap(),
9097            )
9098            .await
9099            .unwrap();
9100        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9101    }
9102
9103    #[tokio::test]
9104    async fn http_kg_query_returns_422_for_oversized_max_depth() {
9105        // The DB layer rejects max_depth > supported with an error whose
9106        // message contains "max_depth"; the handler must return 422.
9107        let state = test_state();
9108        let app = Router::new()
9109            .route("/api/v1/kg/query", axum_post(kg_query))
9110            .with_state(state);
9111        let body = serde_json::json!({
9112            "source_id": "11111111-1111-4111-8111-111111111111",
9113            "max_depth": 999_usize,
9114        });
9115        let resp = app
9116            .oneshot(
9117                axum::http::Request::builder()
9118                    .uri("/api/v1/kg/query")
9119                    .method("POST")
9120                    .header("content-type", "application/json")
9121                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9122                    .unwrap(),
9123            )
9124            .await
9125            .unwrap();
9126        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
9127    }
9128
9129    #[tokio::test]
9130    async fn http_kg_query_returns_422_for_zero_max_depth() {
9131        // The DB layer rejects max_depth=0 with "max_depth must be >= 1";
9132        // handler routes that to 422.
9133        let state = test_state();
9134        let app = Router::new()
9135            .route("/api/v1/kg/query", axum_post(kg_query))
9136            .with_state(state);
9137        let body = serde_json::json!({
9138            "source_id": "11111111-1111-4111-8111-111111111111",
9139            "max_depth": 0_usize,
9140        });
9141        let resp = app
9142            .oneshot(
9143                axum::http::Request::builder()
9144                    .uri("/api/v1/kg/query")
9145                    .method("POST")
9146                    .header("content-type", "application/json")
9147                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9148                    .unwrap(),
9149            )
9150            .await
9151            .unwrap();
9152        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
9153    }
9154
9155    #[tokio::test]
9156    async fn http_kg_query_returns_empty_for_unlinked_source() {
9157        // Real source memory but no links → 200 with count=0.
9158        let state = test_state();
9159        let id = {
9160            let lock = state.lock().await;
9161            let now = Utc::now().to_rfc3339();
9162            let mem = Memory {
9163                id: Uuid::new_v4().to_string(),
9164                tier: Tier::Long,
9165                namespace: "kg-q".into(),
9166                title: "anchor".into(),
9167                content: "anchor body".into(),
9168                tags: vec![],
9169                priority: 5,
9170                confidence: 1.0,
9171                source: "test".into(),
9172                access_count: 0,
9173                created_at: now.clone(),
9174                updated_at: now,
9175                last_accessed_at: None,
9176                expires_at: None,
9177                metadata: serde_json::json!({}),
9178            };
9179            db::insert(&lock.0, &mem).unwrap()
9180        };
9181        let app = Router::new()
9182            .route("/api/v1/kg/query", axum_post(kg_query))
9183            .with_state(state);
9184        let body = serde_json::json!({
9185            "source_id": id,
9186            "max_depth": 1_usize,
9187        });
9188        let resp = app
9189            .oneshot(
9190                axum::http::Request::builder()
9191                    .uri("/api/v1/kg/query")
9192                    .method("POST")
9193                    .header("content-type", "application/json")
9194                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9195                    .unwrap(),
9196            )
9197            .await
9198            .unwrap();
9199        assert_eq!(resp.status(), StatusCode::OK);
9200        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9201            .await
9202            .unwrap();
9203        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9204        assert_eq!(v["count"], serde_json::json!(0));
9205        assert_eq!(v["max_depth"], serde_json::json!(1));
9206    }
9207
9208    #[tokio::test]
9209    async fn http_kg_query_short_circuits_empty_allowed_agents() {
9210        // Empty allowed_agents → DB layer short-circuits with empty result.
9211        let state = test_state();
9212        let app = Router::new()
9213            .route("/api/v1/kg/query", axum_post(kg_query))
9214            .with_state(state);
9215        let body = serde_json::json!({
9216            "source_id": "11111111-1111-4111-8111-111111111111",
9217            "allowed_agents": [],
9218        });
9219        let resp = app
9220            .oneshot(
9221                axum::http::Request::builder()
9222                    .uri("/api/v1/kg/query")
9223                    .method("POST")
9224                    .header("content-type", "application/json")
9225                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9226                    .unwrap(),
9227            )
9228            .await
9229            .unwrap();
9230        assert_eq!(resp.status(), StatusCode::OK);
9231        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9232            .await
9233            .unwrap();
9234        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9235        assert_eq!(v["count"], serde_json::json!(0));
9236    }
9237
9238    // ---- delete_link / get_links / forget_memories / list_namespaces ----
9239
9240    #[tokio::test]
9241    async fn http_delete_link_rejects_self_link() {
9242        // delete_link reuses validate_link → self-link rejected with 400.
9243        let state = test_state();
9244        let app = Router::new()
9245            .route("/api/v1/links", axum::routing::delete(delete_link))
9246            .with_state(test_app_state(state));
9247        let body = serde_json::json!({
9248            "source_id": "11111111-1111-4111-8111-111111111111",
9249            "target_id": "11111111-1111-4111-8111-111111111111",
9250            "relation": "related_to",
9251        });
9252        let resp = app
9253            .oneshot(
9254                axum::http::Request::builder()
9255                    .uri("/api/v1/links")
9256                    .method("DELETE")
9257                    .header("content-type", "application/json")
9258                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9259                    .unwrap(),
9260            )
9261            .await
9262            .unwrap();
9263        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9264    }
9265
9266    #[tokio::test]
9267    async fn http_delete_link_returns_deleted_false_when_missing() {
9268        let state = test_state();
9269        let app = Router::new()
9270            .route("/api/v1/links", axum::routing::delete(delete_link))
9271            .with_state(test_app_state(state));
9272        let body = serde_json::json!({
9273            "source_id": "11111111-1111-4111-8111-111111111111",
9274            "target_id": "22222222-2222-4222-8222-222222222222",
9275            "relation": "related_to",
9276        });
9277        let resp = app
9278            .oneshot(
9279                axum::http::Request::builder()
9280                    .uri("/api/v1/links")
9281                    .method("DELETE")
9282                    .header("content-type", "application/json")
9283                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9284                    .unwrap(),
9285            )
9286            .await
9287            .unwrap();
9288        assert_eq!(resp.status(), StatusCode::OK);
9289        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9290            .await
9291            .unwrap();
9292        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9293        assert_eq!(v["deleted"], serde_json::json!(false));
9294    }
9295
9296    #[tokio::test]
9297    async fn http_get_links_for_unknown_id_returns_empty_array() {
9298        // Unknown ID (well-formed but no row) → 200 OK + empty links.
9299        // validate_id only rejects empty/oversized/control-char strings,
9300        // so an unrecognised but well-formed id still reaches the DB layer.
9301        let state = test_state();
9302        let app = Router::new()
9303            .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
9304            .with_state(state);
9305        let resp = app
9306            .oneshot(
9307                axum::http::Request::builder()
9308                    .uri("/api/v1/memories/nonexistent-id/links")
9309                    .body(Body::empty())
9310                    .unwrap(),
9311            )
9312            .await
9313            .unwrap();
9314        assert_eq!(resp.status(), StatusCode::OK);
9315        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9316            .await
9317            .unwrap();
9318        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9319        assert!(v["links"].is_array());
9320        assert_eq!(v["links"].as_array().unwrap().len(), 0);
9321    }
9322
9323    #[tokio::test]
9324    async fn http_get_links_returns_empty_array_for_unlinked_id() {
9325        let state = test_state();
9326        let id = {
9327            let lock = state.lock().await;
9328            let now = Utc::now().to_rfc3339();
9329            let mem = Memory {
9330                id: Uuid::new_v4().to_string(),
9331                tier: Tier::Long,
9332                namespace: "links-test".into(),
9333                title: "anchor".into(),
9334                content: "no links yet".into(),
9335                tags: vec![],
9336                priority: 5,
9337                confidence: 1.0,
9338                source: "test".into(),
9339                access_count: 0,
9340                created_at: now.clone(),
9341                updated_at: now,
9342                last_accessed_at: None,
9343                expires_at: None,
9344                metadata: serde_json::json!({}),
9345            };
9346            db::insert(&lock.0, &mem).unwrap()
9347        };
9348        let app = Router::new()
9349            .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
9350            .with_state(state);
9351        let resp = app
9352            .oneshot(
9353                axum::http::Request::builder()
9354                    .uri(format!("/api/v1/memories/{id}/links"))
9355                    .body(Body::empty())
9356                    .unwrap(),
9357            )
9358            .await
9359            .unwrap();
9360        assert_eq!(resp.status(), StatusCode::OK);
9361        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9362            .await
9363            .unwrap();
9364        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9365        assert!(v["links"].is_array());
9366        assert_eq!(v["links"].as_array().unwrap().len(), 0);
9367    }
9368
9369    #[tokio::test]
9370    async fn http_list_namespaces_returns_empty_for_fresh_db() {
9371        let state = test_state();
9372        let app = Router::new()
9373            .route("/api/v1/namespaces", axum::routing::get(list_namespaces))
9374            .with_state(state);
9375        let resp = app
9376            .oneshot(
9377                axum::http::Request::builder()
9378                    .uri("/api/v1/namespaces")
9379                    .body(Body::empty())
9380                    .unwrap(),
9381            )
9382            .await
9383            .unwrap();
9384        assert_eq!(resp.status(), StatusCode::OK);
9385        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9386            .await
9387            .unwrap();
9388        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9389        assert!(v["namespaces"].is_array());
9390    }
9391
9392    #[tokio::test]
9393    async fn http_forget_memories_with_namespace_filter_returns_count() {
9394        // Pre-seed two rows in a target namespace, then POST forget.
9395        let state = test_state();
9396        {
9397            let lock = state.lock().await;
9398            let now = Utc::now().to_rfc3339();
9399            for i in 0..3 {
9400                let mem = Memory {
9401                    id: Uuid::new_v4().to_string(),
9402                    tier: Tier::Long,
9403                    namespace: "forget-target".into(),
9404                    title: format!("row-{i}"),
9405                    content: format!("content {i}"),
9406                    tags: vec![],
9407                    priority: 5,
9408                    confidence: 1.0,
9409                    source: "test".into(),
9410                    access_count: 0,
9411                    created_at: now.clone(),
9412                    updated_at: now.clone(),
9413                    last_accessed_at: None,
9414                    expires_at: None,
9415                    metadata: serde_json::json!({}),
9416                };
9417                db::insert(&lock.0, &mem).unwrap();
9418            }
9419        }
9420        let app = Router::new()
9421            .route("/api/v1/forget", axum_post(forget_memories))
9422            .with_state(state);
9423        let body = serde_json::json!({"namespace": "forget-target"});
9424        let resp = app
9425            .oneshot(
9426                axum::http::Request::builder()
9427                    .uri("/api/v1/forget")
9428                    .method("POST")
9429                    .header("content-type", "application/json")
9430                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9431                    .unwrap(),
9432            )
9433            .await
9434            .unwrap();
9435        assert_eq!(resp.status(), StatusCode::OK);
9436        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9437            .await
9438            .unwrap();
9439        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9440        // count of deleted rows is reported under "deleted"
9441        assert!(v["deleted"].as_u64().is_some());
9442    }
9443
9444    // ---- archive_stats / archive_by_ids zero-id batch ----
9445
9446    #[tokio::test]
9447    async fn http_archive_stats_empty_db_returns_zero() {
9448        let state = test_state();
9449        let app = Router::new()
9450            .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
9451            .with_state(state);
9452        let resp = app
9453            .oneshot(
9454                axum::http::Request::builder()
9455                    .uri("/api/v1/archive/stats")
9456                    .body(Body::empty())
9457                    .unwrap(),
9458            )
9459            .await
9460            .unwrap();
9461        assert_eq!(resp.status(), StatusCode::OK);
9462    }
9463
9464    #[tokio::test]
9465    async fn http_purge_archive_returns_zero_for_empty_archive() {
9466        let state = test_state();
9467        let app = Router::new()
9468            .route("/api/v1/archive/purge", axum_post(purge_archive))
9469            .with_state(state);
9470        let resp = app
9471            .oneshot(
9472                axum::http::Request::builder()
9473                    .uri("/api/v1/archive/purge")
9474                    .method("POST")
9475                    .body(Body::empty())
9476                    .unwrap(),
9477            )
9478            .await
9479            .unwrap();
9480        assert_eq!(resp.status(), StatusCode::OK);
9481        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9482            .await
9483            .unwrap();
9484        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9485        assert_eq!(v["purged"], serde_json::json!(0));
9486    }
9487
9488    // ---- run_gc / export_memories / import_memories ----
9489
9490    #[tokio::test]
9491    async fn http_run_gc_returns_zero_for_clean_db() {
9492        let state = test_state();
9493        let app = Router::new()
9494            .route("/api/v1/gc", axum_post(run_gc))
9495            .with_state(state);
9496        let resp = app
9497            .oneshot(
9498                axum::http::Request::builder()
9499                    .uri("/api/v1/gc")
9500                    .method("POST")
9501                    .body(Body::empty())
9502                    .unwrap(),
9503            )
9504            .await
9505            .unwrap();
9506        assert_eq!(resp.status(), StatusCode::OK);
9507    }
9508
9509    #[tokio::test]
9510    async fn http_export_memories_empty_returns_zero_count() {
9511        let state = test_state();
9512        let app = Router::new()
9513            .route("/api/v1/export", axum::routing::get(export_memories))
9514            .with_state(state);
9515        let resp = app
9516            .oneshot(
9517                axum::http::Request::builder()
9518                    .uri("/api/v1/export")
9519                    .body(Body::empty())
9520                    .unwrap(),
9521            )
9522            .await
9523            .unwrap();
9524        assert_eq!(resp.status(), StatusCode::OK);
9525        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9526            .await
9527            .unwrap();
9528        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9529        assert_eq!(v["count"], serde_json::json!(0));
9530    }
9531
9532    #[tokio::test]
9533    async fn http_import_memories_oversized_batch_rejected() {
9534        let state = test_state();
9535        let app = Router::new()
9536            .route("/api/v1/import", axum_post(import_memories))
9537            .with_state(state);
9538        // MAX_BULK_SIZE+1 stub rows. We use minimal Memory payloads so
9539        // serialisation is cheap.
9540        let many: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
9541            .map(|i| {
9542                serde_json::json!({
9543                    "id": format!("11111111-1111-4111-8111-{:012}", i),
9544                    "tier": "long",
9545                    "namespace": "imp",
9546                    "title": format!("t-{i}"),
9547                    "content": "x",
9548                    "tags": [],
9549                    "priority": 5,
9550                    "confidence": 1.0,
9551                    "source": "import",
9552                    "access_count": 0,
9553                    "created_at": "2026-01-01T00:00:00Z",
9554                    "updated_at": "2026-01-01T00:00:00Z",
9555                    "last_accessed_at": null,
9556                    "expires_at": null,
9557                    "metadata": {},
9558                })
9559            })
9560            .collect();
9561        let body = serde_json::json!({"memories": many});
9562        let resp = app
9563            .oneshot(
9564                axum::http::Request::builder()
9565                    .uri("/api/v1/import")
9566                    .method("POST")
9567                    .header("content-type", "application/json")
9568                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9569                    .unwrap(),
9570            )
9571            .await
9572            .unwrap();
9573        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9574    }
9575
9576    #[tokio::test]
9577    async fn http_import_memories_skips_invalid_rows() {
9578        // One valid + one invalid (missing required fields) → 200 with errors.
9579        let state = test_state();
9580        let app = Router::new()
9581            .route("/api/v1/import", axum_post(import_memories))
9582            .with_state(state);
9583        let valid = serde_json::json!({
9584            "id": Uuid::new_v4().to_string(),
9585            "tier": "long",
9586            "namespace": "imp",
9587            "title": "ok-row",
9588            "content": "valid content",
9589            "tags": [],
9590            "priority": 5,
9591            "confidence": 1.0,
9592            "source": "import",
9593            "access_count": 0,
9594            "created_at": "2026-01-01T00:00:00Z",
9595            "updated_at": "2026-01-01T00:00:00Z",
9596            "last_accessed_at": null,
9597            "expires_at": null,
9598            "metadata": {},
9599        });
9600        // Empty title is rejected by validate_memory.
9601        let invalid = serde_json::json!({
9602            "id": Uuid::new_v4().to_string(),
9603            "tier": "long",
9604            "namespace": "imp",
9605            "title": "",
9606            "content": "x",
9607            "tags": [],
9608            "priority": 5,
9609            "confidence": 1.0,
9610            "source": "import",
9611            "access_count": 0,
9612            "created_at": "2026-01-01T00:00:00Z",
9613            "updated_at": "2026-01-01T00:00:00Z",
9614            "last_accessed_at": null,
9615            "expires_at": null,
9616            "metadata": {},
9617        });
9618        let body = serde_json::json!({"memories": [valid, invalid]});
9619        let resp = app
9620            .oneshot(
9621                axum::http::Request::builder()
9622                    .uri("/api/v1/import")
9623                    .method("POST")
9624                    .header("content-type", "application/json")
9625                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9626                    .unwrap(),
9627            )
9628            .await
9629            .unwrap();
9630        assert_eq!(resp.status(), StatusCode::OK);
9631        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9632            .await
9633            .unwrap();
9634        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9635        // Valid row imported = 1; errors array contains the invalid row.
9636        assert_eq!(v["imported"], serde_json::json!(1));
9637        assert!(v["errors"].as_array().unwrap().len() >= 1);
9638    }
9639
9640    // ---- get_stats / get_taxonomy / sync_push pending+meta paths ----
9641
9642    #[tokio::test]
9643    async fn http_get_stats_empty_db() {
9644        let state = test_state();
9645        let app = Router::new()
9646            .route("/api/v1/stats", axum::routing::get(get_stats))
9647            .with_state(state);
9648        let resp = app
9649            .oneshot(
9650                axum::http::Request::builder()
9651                    .uri("/api/v1/stats")
9652                    .body(Body::empty())
9653                    .unwrap(),
9654            )
9655            .await
9656            .unwrap();
9657        assert_eq!(resp.status(), StatusCode::OK);
9658    }
9659
9660    #[tokio::test]
9661    async fn http_sync_push_namespace_meta_clears_garbage_skipped() {
9662        // namespace_meta_clears with a malformed namespace must be skipped
9663        // (not crash, not cleared).
9664        let state = test_state();
9665        let app = Router::new()
9666            .route("/api/v1/sync/push", axum_post(sync_push))
9667            .with_state(test_app_state(state));
9668        let body = serde_json::json!({
9669            "sender_agent_id": "peer-x",
9670            "memories": [],
9671            "namespace_meta_clears": ["BAD NAMESPACE!"],
9672        });
9673        let resp = app
9674            .oneshot(
9675                axum::http::Request::builder()
9676                    .uri("/api/v1/sync/push")
9677                    .method("POST")
9678                    .header("content-type", "application/json")
9679                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9680                    .unwrap(),
9681            )
9682            .await
9683            .unwrap();
9684        assert_eq!(resp.status(), StatusCode::OK);
9685    }
9686
9687    #[tokio::test]
9688    async fn http_sync_push_pending_decision_invalid_id_skipped() {
9689        // pending_decisions with an invalid id must be skipped (not crash).
9690        let state = test_state();
9691        let app = Router::new()
9692            .route("/api/v1/sync/push", axum_post(sync_push))
9693            .with_state(test_app_state(state));
9694        let body = serde_json::json!({
9695            "sender_agent_id": "peer-x",
9696            "memories": [],
9697            "pending_decisions": [
9698                {"id": "BAD ID!", "approved": true, "decider": "alice"}
9699            ],
9700        });
9701        let resp = app
9702            .oneshot(
9703                axum::http::Request::builder()
9704                    .uri("/api/v1/sync/push")
9705                    .method("POST")
9706                    .header("content-type", "application/json")
9707                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9708                    .unwrap(),
9709            )
9710            .await
9711            .unwrap();
9712        assert_eq!(resp.status(), StatusCode::OK);
9713    }
9714
9715    #[tokio::test]
9716    async fn http_sync_push_namespace_meta_invalid_skipped() {
9717        // namespace_meta with an invalid namespace OR invalid standard_id
9718        // should be skipped (incremented under skipped, not applied).
9719        let state = test_state();
9720        let app = Router::new()
9721            .route("/api/v1/sync/push", axum_post(sync_push))
9722            .with_state(test_app_state(state));
9723        let body = serde_json::json!({
9724            "sender_agent_id": "peer-x",
9725            "memories": [],
9726            "namespace_meta": [
9727                {"namespace": "BAD NS!", "standard_id": "11111111-1111-4111-8111-111111111111", "parent_namespace": null}
9728            ],
9729        });
9730        let resp = app
9731            .oneshot(
9732                axum::http::Request::builder()
9733                    .uri("/api/v1/sync/push")
9734                    .method("POST")
9735                    .header("content-type", "application/json")
9736                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9737                    .unwrap(),
9738            )
9739            .await
9740            .unwrap();
9741        assert_eq!(resp.status(), StatusCode::OK);
9742    }
9743
9744    #[tokio::test]
9745    async fn http_sync_push_dry_run_namespace_meta_no_apply() {
9746        // dry_run: namespace_meta entries are counted as noop, not applied.
9747        let state = test_state();
9748        let app = Router::new()
9749            .route("/api/v1/sync/push", axum_post(sync_push))
9750            .with_state(test_app_state(state.clone()));
9751        let body = serde_json::json!({
9752            "sender_agent_id": "peer-x",
9753            "memories": [],
9754            "dry_run": true,
9755            "namespace_meta_clears": ["preview-ns"],
9756            "pending_decisions": [
9757                {"id": "11111111-1111-4111-8111-111111111111", "approved": true, "decider": "alice"}
9758            ],
9759        });
9760        let resp = app
9761            .oneshot(
9762                axum::http::Request::builder()
9763                    .uri("/api/v1/sync/push")
9764                    .method("POST")
9765                    .header("content-type", "application/json")
9766                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9767                    .unwrap(),
9768            )
9769            .await
9770            .unwrap();
9771        assert_eq!(resp.status(), StatusCode::OK);
9772    }
9773
9774    // ----------------------------------------------------------------
9775    // W8 / H8a — archive lane sweep. ~30 tests covering the 6 archive
9776    // handlers (list_archive, archive_by_ids, purge_archive,
9777    // restore_archive, archive_stats, forget_memories) past the
9778    // existing happy-path and validation suites. Reuses
9779    // `test_state`, `test_app_state`, and `insert_test_memory`.
9780    // ----------------------------------------------------------------
9781
9782    // ---- list_archive (5 new) ----
9783
9784    #[tokio::test]
9785    async fn http_list_archive_empty_returns_empty_array() {
9786        // Cold DB: response shape is `{archived: [], count: 0}` with 200.
9787        let state = test_state();
9788        let app = Router::new()
9789            .route("/api/v1/archive", axum::routing::get(list_archive))
9790            .with_state(state);
9791        let resp = app
9792            .oneshot(
9793                axum::http::Request::builder()
9794                    .uri("/api/v1/archive")
9795                    .body(Body::empty())
9796                    .unwrap(),
9797            )
9798            .await
9799            .unwrap();
9800        assert_eq!(resp.status(), StatusCode::OK);
9801        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9802            .await
9803            .unwrap();
9804        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9805        assert_eq!(v["count"], 0);
9806        assert_eq!(v["archived"].as_array().unwrap().len(), 0);
9807    }
9808
9809    #[tokio::test]
9810    async fn http_list_archive_with_items_returns_them() {
9811        // Two archived rows must appear in the listing.
9812        let state = test_state();
9813        let id_a = insert_test_memory(&state, "h8a-list-items", "row-a").await;
9814        let id_b = insert_test_memory(&state, "h8a-list-items", "row-b").await;
9815        {
9816            let lock = state.lock().await;
9817            db::archive_memory(&lock.0, &id_a, Some("test")).unwrap();
9818            db::archive_memory(&lock.0, &id_b, Some("test")).unwrap();
9819        }
9820        let app = Router::new()
9821            .route("/api/v1/archive", axum::routing::get(list_archive))
9822            .with_state(state);
9823        let resp = app
9824            .oneshot(
9825                axum::http::Request::builder()
9826                    .uri("/api/v1/archive?limit=10")
9827                    .body(Body::empty())
9828                    .unwrap(),
9829            )
9830            .await
9831            .unwrap();
9832        assert_eq!(resp.status(), StatusCode::OK);
9833        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9834            .await
9835            .unwrap();
9836        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9837        assert_eq!(v["count"], 2);
9838    }
9839
9840    #[tokio::test]
9841    async fn http_list_archive_pagination_offset_skips() {
9842        // Insert+archive 3 rows; limit=1&offset=1 returns 1 row (the
9843        // middle one by archived_at DESC ordering).
9844        let state = test_state();
9845        let id1 = insert_test_memory(&state, "h8a-page", "row-1").await;
9846        let id2 = insert_test_memory(&state, "h8a-page", "row-2").await;
9847        let id3 = insert_test_memory(&state, "h8a-page", "row-3").await;
9848        {
9849            let lock = state.lock().await;
9850            db::archive_memory(&lock.0, &id1, Some("p")).unwrap();
9851            db::archive_memory(&lock.0, &id2, Some("p")).unwrap();
9852            db::archive_memory(&lock.0, &id3, Some("p")).unwrap();
9853        }
9854        let app = Router::new()
9855            .route("/api/v1/archive", axum::routing::get(list_archive))
9856            .with_state(state);
9857        let resp = app
9858            .oneshot(
9859                axum::http::Request::builder()
9860                    .uri("/api/v1/archive?limit=1&offset=1")
9861                    .body(Body::empty())
9862                    .unwrap(),
9863            )
9864            .await
9865            .unwrap();
9866        assert_eq!(resp.status(), StatusCode::OK);
9867        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9868            .await
9869            .unwrap();
9870        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9871        assert_eq!(v["count"], 1);
9872    }
9873
9874    #[tokio::test]
9875    async fn http_list_archive_namespace_filter_excludes_others() {
9876        // Archive rows in two namespaces; filtering by one returns
9877        // only that namespace's rows.
9878        let state = test_state();
9879        let id_a = insert_test_memory(&state, "h8a-ns-a", "row-a").await;
9880        let id_b = insert_test_memory(&state, "h8a-ns-b", "row-b").await;
9881        {
9882            let lock = state.lock().await;
9883            db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
9884            db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
9885        }
9886        let app = Router::new()
9887            .route("/api/v1/archive", axum::routing::get(list_archive))
9888            .with_state(state);
9889        let resp = app
9890            .oneshot(
9891                axum::http::Request::builder()
9892                    .uri("/api/v1/archive?namespace=h8a-ns-a&limit=10")
9893                    .body(Body::empty())
9894                    .unwrap(),
9895            )
9896            .await
9897            .unwrap();
9898        assert_eq!(resp.status(), StatusCode::OK);
9899        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9900            .await
9901            .unwrap();
9902        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9903        assert_eq!(v["count"], 1);
9904        let entries = v["archived"].as_array().unwrap();
9905        assert_eq!(entries[0]["namespace"], "h8a-ns-a");
9906    }
9907
9908    #[tokio::test]
9909    async fn http_list_archive_namespace_filter_unknown_returns_empty() {
9910        // Filtering by a namespace with nothing archived yields count=0
9911        // and an empty array (not 404).
9912        let state = test_state();
9913        let id_a = insert_test_memory(&state, "h8a-ns-known", "row-a").await;
9914        {
9915            let lock = state.lock().await;
9916            db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
9917        }
9918        let app = Router::new()
9919            .route("/api/v1/archive", axum::routing::get(list_archive))
9920            .with_state(state);
9921        let resp = app
9922            .oneshot(
9923                axum::http::Request::builder()
9924                    .uri("/api/v1/archive?namespace=h8a-no-such-ns")
9925                    .body(Body::empty())
9926                    .unwrap(),
9927            )
9928            .await
9929            .unwrap();
9930        assert_eq!(resp.status(), StatusCode::OK);
9931        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9932            .await
9933            .unwrap();
9934        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9935        assert_eq!(v["count"], 0);
9936    }
9937
9938    // ---- archive_by_ids (5 new) ----
9939
9940    #[tokio::test]
9941    async fn http_archive_by_ids_single_id_success() {
9942        // One id, no fanout — happy path returns 200 with archived=[id].
9943        let state = test_state();
9944        let id = insert_test_memory(&state, "h8a-aby-single", "row").await;
9945        let app = Router::new()
9946            .route("/api/v1/archive", axum_post(archive_by_ids))
9947            .with_state(test_app_state(state.clone()));
9948        let body = serde_json::json!({"ids": [id], "reason": "h8a-single"});
9949        let resp = app
9950            .oneshot(
9951                axum::http::Request::builder()
9952                    .uri("/api/v1/archive")
9953                    .method("POST")
9954                    .header("content-type", "application/json")
9955                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9956                    .unwrap(),
9957            )
9958            .await
9959            .unwrap();
9960        assert_eq!(resp.status(), StatusCode::OK);
9961        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9962            .await
9963            .unwrap();
9964        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9965        assert_eq!(v["count"], 1);
9966        assert_eq!(v["missing"].as_array().unwrap().len(), 0);
9967        assert_eq!(v["reason"], "h8a-single");
9968    }
9969
9970    #[tokio::test]
9971    async fn http_archive_by_ids_bulk_success() {
9972        // Three live ids in one request — all archived, none missing.
9973        let state = test_state();
9974        let id1 = insert_test_memory(&state, "h8a-bulk", "row-1").await;
9975        let id2 = insert_test_memory(&state, "h8a-bulk", "row-2").await;
9976        let id3 = insert_test_memory(&state, "h8a-bulk", "row-3").await;
9977        let app = Router::new()
9978            .route("/api/v1/archive", axum_post(archive_by_ids))
9979            .with_state(test_app_state(state.clone()));
9980        let body = serde_json::json!({"ids": [id1, id2, id3]});
9981        let resp = app
9982            .oneshot(
9983                axum::http::Request::builder()
9984                    .uri("/api/v1/archive")
9985                    .method("POST")
9986                    .header("content-type", "application/json")
9987                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
9988                    .unwrap(),
9989            )
9990            .await
9991            .unwrap();
9992        assert_eq!(resp.status(), StatusCode::OK);
9993        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9994            .await
9995            .unwrap();
9996        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9997        assert_eq!(v["count"], 3);
9998        assert_eq!(v["missing"].as_array().unwrap().len(), 0);
9999    }
10000
10001    #[tokio::test]
10002    async fn http_archive_by_ids_empty_array_returns_ok_zero_count() {
10003        // Empty `ids` array is not an error — returns 200 with zero
10004        // archived and zero missing. (No batch-size violation, no rows.)
10005        let state = test_state();
10006        let app = Router::new()
10007            .route("/api/v1/archive", axum_post(archive_by_ids))
10008            .with_state(test_app_state(state.clone()));
10009        let body = serde_json::json!({"ids": []});
10010        let resp = app
10011            .oneshot(
10012                axum::http::Request::builder()
10013                    .uri("/api/v1/archive")
10014                    .method("POST")
10015                    .header("content-type", "application/json")
10016                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10017                    .unwrap(),
10018            )
10019            .await
10020            .unwrap();
10021        assert_eq!(resp.status(), StatusCode::OK);
10022        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10023            .await
10024            .unwrap();
10025        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10026        assert_eq!(v["count"], 0);
10027        assert_eq!(v["archived"].as_array().unwrap().len(), 0);
10028        assert_eq!(v["missing"].as_array().unwrap().len(), 0);
10029    }
10030
10031    #[tokio::test]
10032    async fn http_archive_by_ids_missing_ids_field_returns_400() {
10033        // Missing required `ids` field → 400 (axum Json extractor rejects
10034        // body that doesn't deserialize).
10035        let state = test_state();
10036        let app = Router::new()
10037            .route("/api/v1/archive", axum_post(archive_by_ids))
10038            .with_state(test_app_state(state));
10039        let body = serde_json::json!({"reason": "no-ids-field"});
10040        let resp = app
10041            .oneshot(
10042                axum::http::Request::builder()
10043                    .uri("/api/v1/archive")
10044                    .method("POST")
10045                    .header("content-type", "application/json")
10046                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10047                    .unwrap(),
10048            )
10049            .await
10050            .unwrap();
10051        assert!(resp.status().is_client_error());
10052    }
10053
10054    #[tokio::test]
10055    async fn http_archive_by_ids_malformed_json_returns_400() {
10056        // Garbage bytes for the body → 400.
10057        let state = test_state();
10058        let app = Router::new()
10059            .route("/api/v1/archive", axum_post(archive_by_ids))
10060            .with_state(test_app_state(state));
10061        let resp = app
10062            .oneshot(
10063                axum::http::Request::builder()
10064                    .uri("/api/v1/archive")
10065                    .method("POST")
10066                    .header("content-type", "application/json")
10067                    .body(Body::from("not-valid-json{{"))
10068                    .unwrap(),
10069            )
10070            .await
10071            .unwrap();
10072        assert!(resp.status().is_client_error());
10073    }
10074
10075    // ---- purge_archive (4 new) ----
10076
10077    #[tokio::test]
10078    async fn http_purge_archive_older_than_keeps_recent() {
10079        // older_than_days=365 against archived rows whose archived_at is
10080        // "now" must purge zero rows (none are older than a year).
10081        let state = test_state();
10082        let id = insert_test_memory(&state, "h8a-purge-recent", "row").await;
10083        {
10084            let lock = state.lock().await;
10085            db::archive_memory(&lock.0, &id, Some("recent")).unwrap();
10086        }
10087        let app = Router::new()
10088            .route("/api/v1/archive", axum::routing::delete(purge_archive))
10089            .with_state(state.clone());
10090        let resp = app
10091            .oneshot(
10092                axum::http::Request::builder()
10093                    .uri("/api/v1/archive?older_than_days=365")
10094                    .method("DELETE")
10095                    .body(Body::empty())
10096                    .unwrap(),
10097            )
10098            .await
10099            .unwrap();
10100        assert_eq!(resp.status(), StatusCode::OK);
10101        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10102            .await
10103            .unwrap();
10104        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10105        assert_eq!(v["purged"], 0);
10106        // Row still in archive.
10107        let lock = state.lock().await;
10108        let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
10109        assert_eq!(rows.len(), 1);
10110    }
10111
10112    #[tokio::test]
10113    async fn http_purge_archive_unfiltered_purges_everything() {
10114        // No `older_than_days` query → purge all archived rows.
10115        let state = test_state();
10116        for i in 0..3 {
10117            let id = insert_test_memory(&state, "h8a-purge-all", &format!("row-{i}")).await;
10118            let lock = state.lock().await;
10119            db::archive_memory(&lock.0, &id, Some("all")).unwrap();
10120        }
10121        let app = Router::new()
10122            .route("/api/v1/archive", axum::routing::delete(purge_archive))
10123            .with_state(state.clone());
10124        let resp = app
10125            .oneshot(
10126                axum::http::Request::builder()
10127                    .uri("/api/v1/archive")
10128                    .method("DELETE")
10129                    .body(Body::empty())
10130                    .unwrap(),
10131            )
10132            .await
10133            .unwrap();
10134        assert_eq!(resp.status(), StatusCode::OK);
10135        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10136            .await
10137            .unwrap();
10138        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10139        assert_eq!(v["purged"], 3);
10140        let lock = state.lock().await;
10141        let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
10142        assert!(rows.is_empty());
10143    }
10144
10145    #[tokio::test]
10146    async fn http_purge_archive_zero_days_purges_all_archived() {
10147        // older_than_days=0 → cutoff is "now", so every archived row is
10148        // older than the cutoff and gets purged.
10149        let state = test_state();
10150        let id = insert_test_memory(&state, "h8a-purge-zero", "row").await;
10151        {
10152            let lock = state.lock().await;
10153            db::archive_memory(&lock.0, &id, Some("zero")).unwrap();
10154        }
10155        let app = Router::new()
10156            .route("/api/v1/archive", axum::routing::delete(purge_archive))
10157            .with_state(state.clone());
10158        let resp = app
10159            .oneshot(
10160                axum::http::Request::builder()
10161                    .uri("/api/v1/archive?older_than_days=0")
10162                    .method("DELETE")
10163                    .body(Body::empty())
10164                    .unwrap(),
10165            )
10166            .await
10167            .unwrap();
10168        assert_eq!(resp.status(), StatusCode::OK);
10169        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10170            .await
10171            .unwrap();
10172        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10173        // count of purged rows ≥ 1 (the recent archive is older than `now`).
10174        assert!(v["purged"].as_u64().unwrap() >= 1);
10175    }
10176
10177    #[tokio::test]
10178    async fn http_purge_archive_response_shape_has_purged_key() {
10179        // Smoke: response is a JSON object with a numeric "purged" key
10180        // even when the archive is empty.
10181        let state = test_state();
10182        let app = Router::new()
10183            .route("/api/v1/archive", axum::routing::delete(purge_archive))
10184            .with_state(state);
10185        let resp = app
10186            .oneshot(
10187                axum::http::Request::builder()
10188                    .uri("/api/v1/archive")
10189                    .method("DELETE")
10190                    .body(Body::empty())
10191                    .unwrap(),
10192            )
10193            .await
10194            .unwrap();
10195        assert_eq!(resp.status(), StatusCode::OK);
10196        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10197            .await
10198            .unwrap();
10199        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10200        assert!(v.is_object());
10201        assert!(v["purged"].is_number());
10202    }
10203
10204    // ---- restore_archive (5 new) ----
10205
10206    #[tokio::test]
10207    async fn http_restore_archive_happy_path_and_listed_in_active() {
10208        // Archive then restore: response has restored=true, the row
10209        // is gone from the archive, and is present in the active table.
10210        let state = test_state();
10211        let id = insert_test_memory(&state, "h8a-restore-ok", "row").await;
10212        {
10213            let lock = state.lock().await;
10214            db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
10215        }
10216        let app = Router::new()
10217            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10218            .with_state(test_app_state(state.clone()));
10219        let resp = app
10220            .oneshot(
10221                axum::http::Request::builder()
10222                    .uri(format!("/api/v1/archive/{id}/restore"))
10223                    .method("POST")
10224                    .body(Body::empty())
10225                    .unwrap(),
10226            )
10227            .await
10228            .unwrap();
10229        assert_eq!(resp.status(), StatusCode::OK);
10230        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10231            .await
10232            .unwrap();
10233        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10234        assert_eq!(v["restored"], true);
10235        assert_eq!(v["id"], id);
10236        // Active row exists; archive entry is gone.
10237        let lock = state.lock().await;
10238        let got = db::get(&lock.0, &id).unwrap();
10239        assert!(got.is_some());
10240        let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
10241        assert!(archived.is_empty());
10242    }
10243
10244    #[tokio::test]
10245    async fn http_restore_archive_then_list_archive_excludes_restored() {
10246        // After a restore, GET /api/v1/archive doesn't return the row
10247        // (the archive table no longer holds it).
10248        let state = test_state();
10249        let id = insert_test_memory(&state, "h8a-restore-list", "row").await;
10250        {
10251            let lock = state.lock().await;
10252            db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
10253            // Sanity: archive contains 1.
10254            let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
10255            assert_eq!(rows.len(), 1);
10256        }
10257        let restore_app = Router::new()
10258            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10259            .with_state(test_app_state(state.clone()));
10260        let resp = restore_app
10261            .oneshot(
10262                axum::http::Request::builder()
10263                    .uri(format!("/api/v1/archive/{id}/restore"))
10264                    .method("POST")
10265                    .body(Body::empty())
10266                    .unwrap(),
10267            )
10268            .await
10269            .unwrap();
10270        assert_eq!(resp.status(), StatusCode::OK);
10271
10272        let list_app = Router::new()
10273            .route("/api/v1/archive", axum::routing::get(list_archive))
10274            .with_state(state);
10275        let resp = list_app
10276            .oneshot(
10277                axum::http::Request::builder()
10278                    .uri("/api/v1/archive")
10279                    .body(Body::empty())
10280                    .unwrap(),
10281            )
10282            .await
10283            .unwrap();
10284        assert_eq!(resp.status(), StatusCode::OK);
10285        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10286            .await
10287            .unwrap();
10288        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10289        assert_eq!(v["count"], 0);
10290    }
10291
10292    #[tokio::test]
10293    async fn http_restore_archive_preserves_namespace_and_title() {
10294        // Restored row keeps its original namespace/title (the data is
10295        // copied verbatim back to `memories`).
10296        let state = test_state();
10297        let id = insert_test_memory(&state, "h8a-rest-meta", "preserve-me").await;
10298        {
10299            let lock = state.lock().await;
10300            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
10301        }
10302        let app = Router::new()
10303            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10304            .with_state(test_app_state(state.clone()));
10305        let resp = app
10306            .oneshot(
10307                axum::http::Request::builder()
10308                    .uri(format!("/api/v1/archive/{id}/restore"))
10309                    .method("POST")
10310                    .body(Body::empty())
10311                    .unwrap(),
10312            )
10313            .await
10314            .unwrap();
10315        assert_eq!(resp.status(), StatusCode::OK);
10316        let lock = state.lock().await;
10317        let got = db::get(&lock.0, &id).unwrap().unwrap();
10318        assert_eq!(got.namespace, "h8a-rest-meta");
10319        assert_eq!(got.title, "preserve-me");
10320    }
10321
10322    #[tokio::test]
10323    async fn http_restore_archive_after_purge_returns_404() {
10324        // Archive → purge → restore: the row is gone from the archive
10325        // table so restore returns 404.
10326        let state = test_state();
10327        let id = insert_test_memory(&state, "h8a-rest-purged", "row").await;
10328        {
10329            let lock = state.lock().await;
10330            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
10331            // Purge unconditionally.
10332            db::purge_archive(&lock.0, None).unwrap();
10333        }
10334        let app = Router::new()
10335            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10336            .with_state(test_app_state(state));
10337        let resp = app
10338            .oneshot(
10339                axum::http::Request::builder()
10340                    .uri(format!("/api/v1/archive/{id}/restore"))
10341                    .method("POST")
10342                    .body(Body::empty())
10343                    .unwrap(),
10344            )
10345            .await
10346            .unwrap();
10347        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10348    }
10349
10350    #[tokio::test]
10351    async fn http_restore_archive_oversized_id_returns_400() {
10352        // An id longer than MAX_ID_LEN (128) is rejected by
10353        // validate::validate_id with 400, not handed off to the DB.
10354        let state = test_state();
10355        let app = Router::new()
10356            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10357            .with_state(test_app_state(state));
10358        let huge = "a".repeat(200);
10359        let resp = app
10360            .oneshot(
10361                axum::http::Request::builder()
10362                    .uri(format!("/api/v1/archive/{huge}/restore"))
10363                    .method("POST")
10364                    .body(Body::empty())
10365                    .unwrap(),
10366            )
10367            .await
10368            .unwrap();
10369        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10370    }
10371
10372    // ---- archive_stats (3 new) ----
10373
10374    #[tokio::test]
10375    async fn http_archive_stats_with_data_reports_total_and_breakdown() {
10376        // Two archived rows under one namespace, one under another →
10377        // archived_total=3, by_namespace lists both.
10378        let state = test_state();
10379        let id_a1 = insert_test_memory(&state, "h8a-stats-a", "row-1").await;
10380        let id_a2 = insert_test_memory(&state, "h8a-stats-a", "row-2").await;
10381        let id_b1 = insert_test_memory(&state, "h8a-stats-b", "row-3").await;
10382        {
10383            let lock = state.lock().await;
10384            db::archive_memory(&lock.0, &id_a1, Some("t")).unwrap();
10385            db::archive_memory(&lock.0, &id_a2, Some("t")).unwrap();
10386            db::archive_memory(&lock.0, &id_b1, Some("t")).unwrap();
10387        }
10388        let app = Router::new()
10389            .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10390            .with_state(state);
10391        let resp = app
10392            .oneshot(
10393                axum::http::Request::builder()
10394                    .uri("/api/v1/archive/stats")
10395                    .body(Body::empty())
10396                    .unwrap(),
10397            )
10398            .await
10399            .unwrap();
10400        assert_eq!(resp.status(), StatusCode::OK);
10401        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10402            .await
10403            .unwrap();
10404        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10405        assert_eq!(v["archived_total"], 3);
10406        let by_ns = v["by_namespace"].as_array().unwrap();
10407        assert_eq!(by_ns.len(), 2);
10408        // First entry has the highest count (DESC). ns-a has 2, ns-b has 1.
10409        assert_eq!(by_ns[0]["count"], 2);
10410        assert_eq!(by_ns[0]["namespace"], "h8a-stats-a");
10411    }
10412
10413    #[tokio::test]
10414    async fn http_archive_stats_empty_returns_total_zero_empty_breakdown() {
10415        // Cold DB: archived_total=0, by_namespace=[].
10416        let state = test_state();
10417        let app = Router::new()
10418            .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10419            .with_state(state);
10420        let resp = app
10421            .oneshot(
10422                axum::http::Request::builder()
10423                    .uri("/api/v1/archive/stats")
10424                    .body(Body::empty())
10425                    .unwrap(),
10426            )
10427            .await
10428            .unwrap();
10429        assert_eq!(resp.status(), StatusCode::OK);
10430        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10431            .await
10432            .unwrap();
10433        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10434        assert_eq!(v["archived_total"], 0);
10435        assert!(v["by_namespace"].as_array().unwrap().is_empty());
10436    }
10437
10438    #[tokio::test]
10439    async fn http_archive_stats_unaffected_by_active_rows() {
10440        // Active (non-archived) rows must not appear in archive stats —
10441        // archived_total only counts the `archived_memories` table.
10442        let state = test_state();
10443        // Five active rows, none archived.
10444        for i in 0..5 {
10445            insert_test_memory(&state, "h8a-stats-active", &format!("row-{i}")).await;
10446        }
10447        let app = Router::new()
10448            .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10449            .with_state(state);
10450        let resp = app
10451            .oneshot(
10452                axum::http::Request::builder()
10453                    .uri("/api/v1/archive/stats")
10454                    .body(Body::empty())
10455                    .unwrap(),
10456            )
10457            .await
10458            .unwrap();
10459        assert_eq!(resp.status(), StatusCode::OK);
10460        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10461            .await
10462            .unwrap();
10463        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10464        assert_eq!(v["archived_total"], 0);
10465    }
10466
10467    // ---- forget_memories (6 new) ----
10468
10469    #[tokio::test]
10470    async fn http_forget_memories_no_filter_returns_400() {
10471        // db::forget bails with "at least one of namespace, pattern, or
10472        // tier is required" when all filters are absent — the handler
10473        // surfaces this as 400.
10474        let state = test_state();
10475        let app = Router::new()
10476            .route("/api/v1/forget", axum_post(forget_memories))
10477            .with_state(state);
10478        let body = serde_json::json!({});
10479        let resp = app
10480            .oneshot(
10481                axum::http::Request::builder()
10482                    .uri("/api/v1/forget")
10483                    .method("POST")
10484                    .header("content-type", "application/json")
10485                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10486                    .unwrap(),
10487            )
10488            .await
10489            .unwrap();
10490        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10491    }
10492
10493    #[tokio::test]
10494    async fn http_forget_memories_pattern_only_deletes_matches() {
10495        // FTS pattern "delete-me" must match exactly the rows whose
10496        // content contains it.
10497        let state = test_state();
10498        {
10499            let lock = state.lock().await;
10500            let now = Utc::now().to_rfc3339();
10501            for (i, content) in ["delete-me alpha", "keep-this beta", "delete-me gamma"]
10502                .iter()
10503                .enumerate()
10504            {
10505                let mem = Memory {
10506                    id: Uuid::new_v4().to_string(),
10507                    tier: Tier::Long,
10508                    namespace: "h8a-forget-pat".into(),
10509                    title: format!("row-{i}"),
10510                    content: (*content).into(),
10511                    tags: vec![],
10512                    priority: 5,
10513                    confidence: 1.0,
10514                    source: "test".into(),
10515                    access_count: 0,
10516                    created_at: now.clone(),
10517                    updated_at: now.clone(),
10518                    last_accessed_at: None,
10519                    expires_at: None,
10520                    metadata: serde_json::json!({}),
10521                };
10522                db::insert(&lock.0, &mem).unwrap();
10523            }
10524        }
10525        let app = Router::new()
10526            .route("/api/v1/forget", axum_post(forget_memories))
10527            .with_state(state);
10528        let body = serde_json::json!({"pattern": "delete-me"});
10529        let resp = app
10530            .oneshot(
10531                axum::http::Request::builder()
10532                    .uri("/api/v1/forget")
10533                    .method("POST")
10534                    .header("content-type", "application/json")
10535                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10536                    .unwrap(),
10537            )
10538            .await
10539            .unwrap();
10540        assert_eq!(resp.status(), StatusCode::OK);
10541        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10542            .await
10543            .unwrap();
10544        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10545        // 2 rows had the pattern "delete-me".
10546        assert_eq!(v["deleted"], 2);
10547    }
10548
10549    #[tokio::test]
10550    async fn http_forget_memories_by_tier_only_targets_tier() {
10551        // Mix of Short/Long rows, tier=short forgets only the Short rows.
10552        let state = test_state();
10553        {
10554            let lock = state.lock().await;
10555            let now = Utc::now().to_rfc3339();
10556            for (i, tier) in [Tier::Short, Tier::Short, Tier::Long].iter().enumerate() {
10557                let mem = Memory {
10558                    id: Uuid::new_v4().to_string(),
10559                    tier: tier.clone(),
10560                    namespace: "h8a-forget-tier".into(),
10561                    title: format!("row-{i}"),
10562                    content: format!("content {i}"),
10563                    tags: vec![],
10564                    priority: 5,
10565                    confidence: 1.0,
10566                    source: "test".into(),
10567                    access_count: 0,
10568                    created_at: now.clone(),
10569                    updated_at: now.clone(),
10570                    last_accessed_at: None,
10571                    expires_at: None,
10572                    metadata: serde_json::json!({}),
10573                };
10574                db::insert(&lock.0, &mem).unwrap();
10575            }
10576        }
10577        let app = Router::new()
10578            .route("/api/v1/forget", axum_post(forget_memories))
10579            .with_state(state);
10580        let body = serde_json::json!({"tier": "short"});
10581        let resp = app
10582            .oneshot(
10583                axum::http::Request::builder()
10584                    .uri("/api/v1/forget")
10585                    .method("POST")
10586                    .header("content-type", "application/json")
10587                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10588                    .unwrap(),
10589            )
10590            .await
10591            .unwrap();
10592        assert_eq!(resp.status(), StatusCode::OK);
10593        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10594            .await
10595            .unwrap();
10596        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10597        assert_eq!(v["deleted"], 2);
10598    }
10599
10600    #[tokio::test]
10601    async fn http_forget_memories_combined_filters_intersect() {
10602        // namespace + pattern should AND — only rows in `target-ns`
10603        // matching `purge` are forgotten.
10604        let state = test_state();
10605        {
10606            let lock = state.lock().await;
10607            let now = Utc::now().to_rfc3339();
10608            // 2 in target ns matching the pattern, 1 in target ns not
10609            // matching, 1 in another ns matching.
10610            for (ns, content) in [
10611                ("h8a-forget-and", "purge alpha"),
10612                ("h8a-forget-and", "purge beta"),
10613                ("h8a-forget-and", "keep gamma"),
10614                ("h8a-forget-other", "purge delta"),
10615            ] {
10616                let mem = Memory {
10617                    id: Uuid::new_v4().to_string(),
10618                    tier: Tier::Long,
10619                    namespace: ns.into(),
10620                    title: format!("row-{content}"),
10621                    content: content.into(),
10622                    tags: vec![],
10623                    priority: 5,
10624                    confidence: 1.0,
10625                    source: "test".into(),
10626                    access_count: 0,
10627                    created_at: now.clone(),
10628                    updated_at: now.clone(),
10629                    last_accessed_at: None,
10630                    expires_at: None,
10631                    metadata: serde_json::json!({}),
10632                };
10633                db::insert(&lock.0, &mem).unwrap();
10634            }
10635        }
10636        let app = Router::new()
10637            .route("/api/v1/forget", axum_post(forget_memories))
10638            .with_state(state);
10639        let body = serde_json::json!({
10640            "namespace": "h8a-forget-and",
10641            "pattern": "purge"
10642        });
10643        let resp = app
10644            .oneshot(
10645                axum::http::Request::builder()
10646                    .uri("/api/v1/forget")
10647                    .method("POST")
10648                    .header("content-type", "application/json")
10649                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10650                    .unwrap(),
10651            )
10652            .await
10653            .unwrap();
10654        assert_eq!(resp.status(), StatusCode::OK);
10655        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10656            .await
10657            .unwrap();
10658        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10659        // 2 rows in target ns matched the pattern.
10660        assert_eq!(v["deleted"], 2);
10661    }
10662
10663    #[tokio::test]
10664    async fn http_forget_memories_malformed_json_returns_400() {
10665        // Garbage body → 400 (Json extractor rejects).
10666        let state = test_state();
10667        let app = Router::new()
10668            .route("/api/v1/forget", axum_post(forget_memories))
10669            .with_state(state);
10670        let resp = app
10671            .oneshot(
10672                axum::http::Request::builder()
10673                    .uri("/api/v1/forget")
10674                    .method("POST")
10675                    .header("content-type", "application/json")
10676                    .body(Body::from("{not-json"))
10677                    .unwrap(),
10678            )
10679            .await
10680            .unwrap();
10681        assert!(resp.status().is_client_error());
10682    }
10683
10684    #[tokio::test]
10685    async fn http_forget_memories_no_match_returns_zero_deleted() {
10686        // namespace filter that matches nothing → 200 with deleted=0.
10687        let state = test_state();
10688        // Seed a few rows in a *different* namespace so the table isn't
10689        // wholly empty (forget shouldn't touch them).
10690        for i in 0..3 {
10691            insert_test_memory(&state, "h8a-forget-keep", &format!("k-{i}")).await;
10692        }
10693        let app = Router::new()
10694            .route("/api/v1/forget", axum_post(forget_memories))
10695            .with_state(state.clone());
10696        let body = serde_json::json!({"namespace": "h8a-forget-empty"});
10697        let resp = app
10698            .oneshot(
10699                axum::http::Request::builder()
10700                    .uri("/api/v1/forget")
10701                    .method("POST")
10702                    .header("content-type", "application/json")
10703                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10704                    .unwrap(),
10705            )
10706            .await
10707            .unwrap();
10708        assert_eq!(resp.status(), StatusCode::OK);
10709        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10710            .await
10711            .unwrap();
10712        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10713        assert_eq!(v["deleted"], 0);
10714        // The keep namespace still has 3 rows.
10715        let lock = state.lock().await;
10716        let rows = db::list(
10717            &lock.0,
10718            Some("h8a-forget-keep"),
10719            None,
10720            10,
10721            0,
10722            None,
10723            None,
10724            None,
10725            None,
10726            None,
10727        )
10728        .unwrap();
10729        assert_eq!(rows.len(), 3);
10730    }
10731    // -------------------------------------------------------------------
10732    // Wave 8 (Closer H8b) — handlers.rs inbox/subscriptions lane.
10733    //
10734    // Targets the six handler entry points that drive S32/S33/S36:
10735    //   - subscribe / unsubscribe / list_subscriptions
10736    //   - notify / get_inbox
10737    //   - session_start
10738    //
10739    // All tests run in-process against a `:memory:` DB with `federation =
10740    // None` so the quorum branches stay short-circuited. We exercise the
10741    // happy path *and* the validation/error edges — the latter is where
10742    // pre-W8 coverage was thin (~81% on handlers.rs).
10743    // -------------------------------------------------------------------
10744
10745    // ---- subscribe (POST /api/v1/subscriptions) ----
10746
10747    /// Happy path: a valid `https://` webhook URL produces a 201 with the
10748    /// canonical webhook-shape echo (`id`, `url`, `events`, `created_by`).
10749    #[tokio::test]
10750    async fn h8b_subscribe_https_url_returns_created() {
10751        let state = test_state();
10752        let app = Router::new()
10753            .route("/api/v1/subscriptions", axum_post(subscribe))
10754            .with_state(test_app_state(state));
10755
10756        let body = serde_json::json!({
10757            "url": "https://example.com/webhook",
10758            "events": "*",
10759        });
10760        let resp = app
10761            .oneshot(
10762                axum::http::Request::builder()
10763                    .uri("/api/v1/subscriptions")
10764                    .method("POST")
10765                    .header("content-type", "application/json")
10766                    .header("x-agent-id", "alice")
10767                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10768                    .unwrap(),
10769            )
10770            .await
10771            .unwrap();
10772        assert_eq!(resp.status(), StatusCode::CREATED);
10773        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10774            .await
10775            .unwrap();
10776        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10777        assert!(v["id"].as_str().is_some(), "id must be returned");
10778        assert_eq!(v["url"], "https://example.com/webhook");
10779        assert_eq!(v["created_by"], "alice");
10780    }
10781
10782    /// Body without `url` *or* `namespace` is rejected with 400 — the
10783    /// handler short-circuits before touching the DB.
10784    #[tokio::test]
10785    async fn h8b_subscribe_missing_url_and_namespace_rejected() {
10786        let state = test_state();
10787        let app = Router::new()
10788            .route("/api/v1/subscriptions", axum_post(subscribe))
10789            .with_state(test_app_state(state));
10790
10791        let body = serde_json::json!({"events": "*"});
10792        let resp = app
10793            .oneshot(
10794                axum::http::Request::builder()
10795                    .uri("/api/v1/subscriptions")
10796                    .method("POST")
10797                    .header("content-type", "application/json")
10798                    .header("x-agent-id", "alice")
10799                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10800                    .unwrap(),
10801            )
10802            .await
10803            .unwrap();
10804        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10805        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10806            .await
10807            .unwrap();
10808        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10809        assert!(v["error"].as_str().unwrap().contains("url or namespace"),);
10810    }
10811
10812    /// A URL missing the scheme is invalid (`validate_url` reports "missing
10813    /// scheme"). Handler must surface this as 400.
10814    #[tokio::test]
10815    async fn h8b_subscribe_invalid_url_rejected() {
10816        let state = test_state();
10817        let app = Router::new()
10818            .route("/api/v1/subscriptions", axum_post(subscribe))
10819            .with_state(test_app_state(state));
10820
10821        let body = serde_json::json!({
10822            "url": "not-a-url",
10823            "events": "*",
10824        });
10825        let resp = app
10826            .oneshot(
10827                axum::http::Request::builder()
10828                    .uri("/api/v1/subscriptions")
10829                    .method("POST")
10830                    .header("content-type", "application/json")
10831                    .header("x-agent-id", "alice")
10832                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10833                    .unwrap(),
10834            )
10835            .await
10836            .unwrap();
10837        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10838    }
10839
10840    /// SSRF guard: explicit loopback (127.0.0.1) is permitted (matches the
10841    /// `is_loopback()` allowance in `validate_url`); but a metadata-service
10842    /// IP (169.254.169.254 — link-local) must be rejected. Both cases share
10843    /// the same handler entry-point so we exercise them together.
10844    #[tokio::test]
10845    async fn h8b_subscribe_rejects_link_local_metadata_ip() {
10846        let state = test_state();
10847        let app = Router::new()
10848            .route("/api/v1/subscriptions", axum_post(subscribe))
10849            .with_state(test_app_state(state));
10850
10851        let body = serde_json::json!({
10852            "url": "https://169.254.169.254/latest/meta-data/",
10853            "events": "*",
10854        });
10855        let resp = app
10856            .oneshot(
10857                axum::http::Request::builder()
10858                    .uri("/api/v1/subscriptions")
10859                    .method("POST")
10860                    .header("content-type", "application/json")
10861                    .header("x-agent-id", "alice")
10862                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10863                    .unwrap(),
10864            )
10865            .await
10866            .unwrap();
10867        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10868        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10869            .await
10870            .unwrap();
10871        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10872        let err = v["error"].as_str().unwrap();
10873        // The validator rejects with either "private", "link-local", or
10874        // similar wording — accept any of the SSRF-guard messages.
10875        assert!(
10876            err.contains("private") || err.contains("link-local") || err.contains("non-loopback"),
10877            "expected SSRF rejection, got: {err}",
10878        );
10879    }
10880
10881    /// S33 namespace-shape: when only `namespace` is supplied the handler
10882    /// synthesizes a loopback URL and persists `namespace_filter`.
10883    #[tokio::test]
10884    async fn h8b_subscribe_namespace_shape_synthesizes_url() {
10885        let state = test_state();
10886        let app = Router::new()
10887            .route("/api/v1/subscriptions", axum_post(subscribe))
10888            .with_state(test_app_state(state));
10889
10890        let body = serde_json::json!({
10891            "agent_id": "alice",
10892            "namespace": "team/research",
10893        });
10894        let resp = app
10895            .oneshot(
10896                axum::http::Request::builder()
10897                    .uri("/api/v1/subscriptions")
10898                    .method("POST")
10899                    .header("content-type", "application/json")
10900                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10901                    .unwrap(),
10902            )
10903            .await
10904            .unwrap();
10905        assert_eq!(resp.status(), StatusCode::CREATED);
10906        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10907            .await
10908            .unwrap();
10909        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10910        assert_eq!(v["agent_id"], "alice");
10911        assert_eq!(v["namespace"], "team/research");
10912        assert!(
10913            v["url"]
10914                .as_str()
10915                .unwrap()
10916                .starts_with("http://localhost/_ns/"),
10917            "expected synthetic URL, got {}",
10918            v["url"],
10919        );
10920    }
10921
10922    /// Webhook body with explicit `events` filter ("memory.created") is
10923    /// accepted and round-tripped back in the response.
10924    #[tokio::test]
10925    async fn h8b_subscribe_event_filter_round_trips() {
10926        let state = test_state();
10927        let app = Router::new()
10928            .route("/api/v1/subscriptions", axum_post(subscribe))
10929            .with_state(test_app_state(state));
10930
10931        let body = serde_json::json!({
10932            "url": "https://example.com/hook",
10933            "events": "memory.created",
10934            "namespace_filter": "global",
10935        });
10936        let resp = app
10937            .oneshot(
10938                axum::http::Request::builder()
10939                    .uri("/api/v1/subscriptions")
10940                    .method("POST")
10941                    .header("content-type", "application/json")
10942                    .header("x-agent-id", "alice")
10943                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10944                    .unwrap(),
10945            )
10946            .await
10947            .unwrap();
10948        assert_eq!(resp.status(), StatusCode::CREATED);
10949        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10950            .await
10951            .unwrap();
10952        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10953        assert_eq!(v["events"], "memory.created");
10954        assert_eq!(v["namespace_filter"], "global");
10955    }
10956
10957    /// HMAC support: `secret` is accepted by the handler. Subscriptions
10958    /// persist the hashed secret so the dispatcher can sign outbound posts.
10959    /// We assert the create call succeeds — the secret must not leak back
10960    /// in the response payload (the handler echoes only id/url/events/etc).
10961    #[tokio::test]
10962    async fn h8b_subscribe_persists_hmac_secret() {
10963        let state = test_state();
10964        let app = Router::new()
10965            .route("/api/v1/subscriptions", axum_post(subscribe))
10966            .with_state(test_app_state(state.clone()));
10967
10968        let body = serde_json::json!({
10969            "url": "https://example.com/signed-hook",
10970            "events": "*",
10971            "secret": "topsecret-hmac-key",
10972        });
10973        let resp = app
10974            .oneshot(
10975                axum::http::Request::builder()
10976                    .uri("/api/v1/subscriptions")
10977                    .method("POST")
10978                    .header("content-type", "application/json")
10979                    .header("x-agent-id", "alice")
10980                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
10981                    .unwrap(),
10982            )
10983            .await
10984            .unwrap();
10985        assert_eq!(resp.status(), StatusCode::CREATED);
10986        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10987            .await
10988            .unwrap();
10989        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10990        // Secret must not be echoed in the response.
10991        assert!(v.get("secret").is_none(), "secret leaked into response");
10992        // The row exists in the DB.
10993        let lock = state.lock().await;
10994        let subs = crate::subscriptions::list(&lock.0).unwrap();
10995        assert_eq!(subs.len(), 1);
10996        assert_eq!(subs[0].url, "https://example.com/signed-hook");
10997    }
10998
10999    // ---- unsubscribe (DELETE /api/v1/subscriptions) ----
11000
11001    /// Happy path: insert a subscription then delete by id; handler returns
11002    /// `removed: true` and the row is gone from the listing.
11003    #[tokio::test]
11004    async fn h8b_unsubscribe_by_id_happy_path() {
11005        let state = test_state();
11006        let id = {
11007            let lock = state.lock().await;
11008            crate::subscriptions::insert(
11009                &lock.0,
11010                &crate::subscriptions::NewSubscription {
11011                    url: "https://example.com/h",
11012                    events: "*",
11013                    secret: None,
11014                    namespace_filter: None,
11015                    agent_filter: None,
11016                    created_by: Some("alice"),
11017                    event_types: None,
11018                },
11019            )
11020            .unwrap()
11021        };
11022
11023        let app = Router::new()
11024            .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11025            .with_state(test_app_state(state.clone()));
11026
11027        let resp = app
11028            .oneshot(
11029                axum::http::Request::builder()
11030                    .uri(format!("/api/v1/subscriptions?id={id}"))
11031                    .method("DELETE")
11032                    .body(Body::empty())
11033                    .unwrap(),
11034            )
11035            .await
11036            .unwrap();
11037        assert_eq!(resp.status(), StatusCode::OK);
11038        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11039            .await
11040            .unwrap();
11041        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11042        assert_eq!(v["removed"], true);
11043
11044        // List must be empty afterwards.
11045        let lock = state.lock().await;
11046        assert!(crate::subscriptions::list(&lock.0).unwrap().is_empty());
11047    }
11048
11049    /// Deleting a nonexistent id returns 200 with `removed: false` — the
11050    /// SQL `DELETE` is idempotent and the handler reports the outcome.
11051    #[tokio::test]
11052    async fn h8b_unsubscribe_nonexistent_id_returns_removed_false() {
11053        let state = test_state();
11054        let app = Router::new()
11055            .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11056            .with_state(test_app_state(state));
11057
11058        let resp = app
11059            .oneshot(
11060                axum::http::Request::builder()
11061                    .uri("/api/v1/subscriptions?id=does-not-exist")
11062                    .method("DELETE")
11063                    .body(Body::empty())
11064                    .unwrap(),
11065            )
11066            .await
11067            .unwrap();
11068        assert_eq!(resp.status(), StatusCode::OK);
11069        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11070            .await
11071            .unwrap();
11072        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11073        assert_eq!(v["removed"], false);
11074    }
11075
11076    /// S33 (agent_id, namespace) shape — handler finds the row by filter
11077    /// and deletes it without needing an explicit id.
11078    #[tokio::test]
11079    async fn h8b_unsubscribe_by_agent_and_namespace() {
11080        let state = test_state();
11081        // Seed a subscription owned by alice for namespace "demo".
11082        {
11083            let lock = state.lock().await;
11084            crate::subscriptions::insert(
11085                &lock.0,
11086                &crate::subscriptions::NewSubscription {
11087                    url: "http://localhost/_ns/alice/demo",
11088                    events: "*",
11089                    secret: None,
11090                    namespace_filter: Some("demo"),
11091                    agent_filter: Some("alice"),
11092                    created_by: Some("alice"),
11093                    event_types: None,
11094                },
11095            )
11096            .unwrap();
11097        }
11098
11099        let app = Router::new()
11100            .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11101            .with_state(test_app_state(state.clone()));
11102
11103        let resp = app
11104            .oneshot(
11105                axum::http::Request::builder()
11106                    .uri("/api/v1/subscriptions?namespace=demo")
11107                    .method("DELETE")
11108                    .header("x-agent-id", "alice")
11109                    .body(Body::empty())
11110                    .unwrap(),
11111            )
11112            .await
11113            .unwrap();
11114        assert_eq!(resp.status(), StatusCode::OK);
11115        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11116            .await
11117            .unwrap();
11118        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11119        assert_eq!(v["removed"], true);
11120    }
11121
11122    /// Neither `id` nor (`agent_id`, `namespace`) is supplied — must 400.
11123    #[tokio::test]
11124    async fn h8b_unsubscribe_missing_id_and_namespace_rejected() {
11125        let state = test_state();
11126        let app = Router::new()
11127            .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11128            .with_state(test_app_state(state));
11129
11130        let resp = app
11131            .oneshot(
11132                axum::http::Request::builder()
11133                    .uri("/api/v1/subscriptions")
11134                    .method("DELETE")
11135                    .header("x-agent-id", "alice")
11136                    .body(Body::empty())
11137                    .unwrap(),
11138            )
11139            .await
11140            .unwrap();
11141        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11142        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11143            .await
11144            .unwrap();
11145        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11146        assert!(
11147            v["error"]
11148                .as_str()
11149                .unwrap()
11150                .contains("id or (agent_id, namespace)"),
11151        );
11152    }
11153
11154    // ---- list_subscriptions (GET /api/v1/subscriptions) ----
11155
11156    /// With seeded data the handler returns rows shaped as the JSON spec
11157    /// (top-level `namespace` field, alongside `namespace_filter`).
11158    #[tokio::test]
11159    async fn h8b_list_subscriptions_returns_seeded_rows() {
11160        let state = test_state();
11161        {
11162            let lock = state.lock().await;
11163            crate::subscriptions::insert(
11164                &lock.0,
11165                &crate::subscriptions::NewSubscription {
11166                    url: "https://example.com/a",
11167                    events: "*",
11168                    secret: None,
11169                    namespace_filter: Some("ns1"),
11170                    agent_filter: Some("alice"),
11171                    created_by: Some("alice"),
11172                    event_types: None,
11173                },
11174            )
11175            .unwrap();
11176            crate::subscriptions::insert(
11177                &lock.0,
11178                &crate::subscriptions::NewSubscription {
11179                    url: "https://example.com/b",
11180                    events: "memory.updated",
11181                    secret: None,
11182                    namespace_filter: Some("ns2"),
11183                    agent_filter: Some("bob"),
11184                    created_by: Some("bob"),
11185                    event_types: None,
11186                },
11187            )
11188            .unwrap();
11189        }
11190
11191        let app = Router::new()
11192            .route(
11193                "/api/v1/subscriptions",
11194                axum::routing::get(list_subscriptions),
11195            )
11196            .with_state(state);
11197
11198        let resp = app
11199            .oneshot(
11200                axum::http::Request::builder()
11201                    .uri("/api/v1/subscriptions")
11202                    .body(Body::empty())
11203                    .unwrap(),
11204            )
11205            .await
11206            .unwrap();
11207        assert_eq!(resp.status(), StatusCode::OK);
11208        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11209            .await
11210            .unwrap();
11211        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11212        assert_eq!(v["count"], 2);
11213        let subs = v["subscriptions"].as_array().unwrap();
11214        assert_eq!(subs.len(), 2);
11215        // Each row has the expected `namespace` projection.
11216        for s in subs {
11217            assert!(s["namespace"].is_string());
11218            assert!(s["namespace_filter"].is_string());
11219            assert!(s["id"].is_string());
11220        }
11221    }
11222
11223    /// Filtering by `agent_id` returns only the rows matching either
11224    /// `agent_filter` or `created_by`. Bob's row must be excluded when
11225    /// alice queries.
11226    #[tokio::test]
11227    async fn h8b_list_subscriptions_agent_id_filter_excludes_others() {
11228        let state = test_state();
11229        {
11230            let lock = state.lock().await;
11231            crate::subscriptions::insert(
11232                &lock.0,
11233                &crate::subscriptions::NewSubscription {
11234                    url: "https://example.com/a",
11235                    events: "*",
11236                    secret: None,
11237                    namespace_filter: Some("ns1"),
11238                    agent_filter: Some("alice"),
11239                    created_by: Some("alice"),
11240                    event_types: None,
11241                },
11242            )
11243            .unwrap();
11244            crate::subscriptions::insert(
11245                &lock.0,
11246                &crate::subscriptions::NewSubscription {
11247                    url: "https://example.com/b",
11248                    events: "*",
11249                    secret: None,
11250                    namespace_filter: Some("ns2"),
11251                    agent_filter: Some("bob"),
11252                    created_by: Some("bob"),
11253                    event_types: None,
11254                },
11255            )
11256            .unwrap();
11257        }
11258
11259        let app = Router::new()
11260            .route(
11261                "/api/v1/subscriptions",
11262                axum::routing::get(list_subscriptions),
11263            )
11264            .with_state(state);
11265
11266        let resp = app
11267            .oneshot(
11268                axum::http::Request::builder()
11269                    .uri("/api/v1/subscriptions?agent_id=alice")
11270                    .body(Body::empty())
11271                    .unwrap(),
11272            )
11273            .await
11274            .unwrap();
11275        assert_eq!(resp.status(), StatusCode::OK);
11276        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11277            .await
11278            .unwrap();
11279        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11280        assert_eq!(v["count"], 1);
11281        assert_eq!(v["subscriptions"][0]["namespace"], "ns1");
11282    }
11283
11284    // ---- notify (POST /api/v1/notify) ----
11285
11286    /// Happy path: alice notifies bob, the response carries the new id and
11287    /// `delivered_at` stamp; the row lands in bob's `_messages/bob` ns.
11288    #[tokio::test]
11289    async fn h8b_notify_happy_path_creates_message() {
11290        let state = test_state();
11291        let app = Router::new()
11292            .route("/api/v1/notify", axum_post(notify))
11293            .with_state(test_app_state(state.clone()));
11294
11295        let body = serde_json::json!({
11296            "target_agent_id": "bob",
11297            "title": "Hi bob",
11298            "payload": "hello there",
11299        });
11300        let resp = app
11301            .oneshot(
11302                axum::http::Request::builder()
11303                    .uri("/api/v1/notify")
11304                    .method("POST")
11305                    .header("content-type", "application/json")
11306                    .header("x-agent-id", "alice")
11307                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11308                    .unwrap(),
11309            )
11310            .await
11311            .unwrap();
11312        assert_eq!(resp.status(), StatusCode::CREATED);
11313        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11314            .await
11315            .unwrap();
11316        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11317        assert_eq!(v["to"], "bob");
11318        assert!(v["id"].as_str().is_some());
11319        assert!(v["delivered_at"].as_str().is_some());
11320
11321        // Row landed in bob's namespace.
11322        let lock = state.lock().await;
11323        let rows = db::list(
11324            &lock.0,
11325            Some("_messages/bob"),
11326            None,
11327            10,
11328            0,
11329            None,
11330            None,
11331            None,
11332            None,
11333            None,
11334        )
11335        .unwrap();
11336        assert_eq!(rows.len(), 1);
11337        assert_eq!(rows[0].title, "Hi bob");
11338    }
11339
11340    /// `target_agent_id` is a required field on `NotifyBody`. Omitting it
11341    /// triggers serde's missing-field rejection (Axum returns 422
11342    /// Unprocessable Entity for malformed JSON shapes).
11343    #[tokio::test]
11344    async fn h8b_notify_missing_target_agent_id_rejected() {
11345        let state = test_state();
11346        let app = Router::new()
11347            .route("/api/v1/notify", axum_post(notify))
11348            .with_state(test_app_state(state));
11349
11350        // Required field absent — handler never runs.
11351        let body = serde_json::json!({
11352            "title": "stray",
11353            "payload": "no target",
11354        });
11355        let resp = app
11356            .oneshot(
11357                axum::http::Request::builder()
11358                    .uri("/api/v1/notify")
11359                    .method("POST")
11360                    .header("content-type", "application/json")
11361                    .header("x-agent-id", "alice")
11362                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11363                    .unwrap(),
11364            )
11365            .await
11366            .unwrap();
11367        // Axum rejects with 422 for missing required JSON fields.
11368        assert!(
11369            resp.status() == StatusCode::UNPROCESSABLE_ENTITY
11370                || resp.status() == StatusCode::BAD_REQUEST,
11371            "expected 4xx for missing target_agent_id, got {}",
11372            resp.status(),
11373        );
11374    }
11375
11376    /// `target_agent_id` containing illegal characters (spaces) is rejected
11377    /// downstream by `validate_agent_id` inside `handle_notify`.
11378    #[tokio::test]
11379    async fn h8b_notify_invalid_target_agent_id_rejected() {
11380        let state = test_state();
11381        let app = Router::new()
11382            .route("/api/v1/notify", axum_post(notify))
11383            .with_state(test_app_state(state));
11384
11385        let body = serde_json::json!({
11386            "target_agent_id": "bob with spaces",
11387            "title": "Hi",
11388            "payload": "hello",
11389        });
11390        let resp = app
11391            .oneshot(
11392                axum::http::Request::builder()
11393                    .uri("/api/v1/notify")
11394                    .method("POST")
11395                    .header("content-type", "application/json")
11396                    .header("x-agent-id", "alice")
11397                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11398                    .unwrap(),
11399            )
11400            .await
11401            .unwrap();
11402        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11403    }
11404
11405    /// Oversized payload ( > MAX_CONTENT_SIZE bytes) is rejected by
11406    /// `validate::validate_content` inside `handle_notify`.
11407    #[tokio::test]
11408    async fn h8b_notify_oversized_payload_rejected() {
11409        let state = test_state();
11410        let app = Router::new()
11411            .route("/api/v1/notify", axum_post(notify))
11412            .with_state(test_app_state(state));
11413
11414        // MAX_CONTENT_SIZE is 65_536; allocate one over.
11415        let big = "a".repeat(65_537);
11416        let body = serde_json::json!({
11417            "target_agent_id": "bob",
11418            "title": "huge",
11419            "payload": big,
11420        });
11421        let resp = app
11422            .oneshot(
11423                axum::http::Request::builder()
11424                    .uri("/api/v1/notify")
11425                    .method("POST")
11426                    .header("content-type", "application/json")
11427                    .header("x-agent-id", "alice")
11428                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11429                    .unwrap(),
11430            )
11431            .await
11432            .unwrap();
11433        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11434        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11435            .await
11436            .unwrap();
11437        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11438        assert!(
11439            v["error"].as_str().unwrap().contains("max"),
11440            "expected size-limit error, got {:?}",
11441            v["error"],
11442        );
11443    }
11444
11445    /// `content` is accepted as an alias for `payload` (S32 scenario uses
11446    /// this shape). The notify completes and lands in the target's inbox.
11447    #[tokio::test]
11448    async fn h8b_notify_accepts_content_alias_for_payload() {
11449        let state = test_state();
11450        let app = Router::new()
11451            .route("/api/v1/notify", axum_post(notify))
11452            .with_state(test_app_state(state));
11453
11454        let body = serde_json::json!({
11455            "target_agent_id": "bob",
11456            "title": "alias",
11457            "content": "via the content field",
11458        });
11459        let resp = app
11460            .oneshot(
11461                axum::http::Request::builder()
11462                    .uri("/api/v1/notify")
11463                    .method("POST")
11464                    .header("content-type", "application/json")
11465                    .header("x-agent-id", "alice")
11466                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11467                    .unwrap(),
11468            )
11469            .await
11470            .unwrap();
11471        assert_eq!(resp.status(), StatusCode::CREATED);
11472    }
11473
11474    // ---- get_inbox (GET /api/v1/inbox) ----
11475
11476    /// Empty inbox returns 200 with `count: 0` and an empty `messages` array.
11477    #[tokio::test]
11478    async fn h8b_get_inbox_empty_returns_zero() {
11479        let state = test_state();
11480        let app = Router::new()
11481            .route("/api/v1/inbox", axum::routing::get(get_inbox))
11482            .with_state(test_app_state(state));
11483
11484        let resp = app
11485            .oneshot(
11486                axum::http::Request::builder()
11487                    .uri("/api/v1/inbox?agent_id=alice")
11488                    .body(Body::empty())
11489                    .unwrap(),
11490            )
11491            .await
11492            .unwrap();
11493        assert_eq!(resp.status(), StatusCode::OK);
11494        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11495            .await
11496            .unwrap();
11497        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11498        assert_eq!(v["count"], 0);
11499        assert_eq!(v["messages"].as_array().unwrap().len(), 0);
11500    }
11501
11502    /// After a notify, the inbox surfaces the message with `from`/`title`
11503    /// fields populated; `read=false` indicates the recipient hasn't
11504    /// touched it yet.
11505    #[tokio::test]
11506    async fn h8b_get_inbox_returns_pending_after_notify() {
11507        let state = test_state();
11508
11509        // Seed via the notify handler so the full stack is exercised.
11510        let notify_app = Router::new()
11511            .route("/api/v1/notify", axum_post(notify))
11512            .with_state(test_app_state(state.clone()));
11513        let notify_body = serde_json::json!({
11514            "target_agent_id": "bob",
11515            "title": "ping",
11516            "payload": "wake up",
11517        });
11518        let resp = notify_app
11519            .oneshot(
11520                axum::http::Request::builder()
11521                    .uri("/api/v1/notify")
11522                    .method("POST")
11523                    .header("content-type", "application/json")
11524                    .header("x-agent-id", "alice")
11525                    .body(Body::from(serde_json::to_vec(&notify_body).unwrap()))
11526                    .unwrap(),
11527            )
11528            .await
11529            .unwrap();
11530        assert_eq!(resp.status(), StatusCode::CREATED);
11531
11532        // Now fetch bob's inbox.
11533        let inbox_app = Router::new()
11534            .route("/api/v1/inbox", axum::routing::get(get_inbox))
11535            .with_state(test_app_state(state));
11536        let resp = inbox_app
11537            .oneshot(
11538                axum::http::Request::builder()
11539                    .uri("/api/v1/inbox?agent_id=bob")
11540                    .body(Body::empty())
11541                    .unwrap(),
11542            )
11543            .await
11544            .unwrap();
11545        assert_eq!(resp.status(), StatusCode::OK);
11546        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11547            .await
11548            .unwrap();
11549        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11550        assert_eq!(v["count"], 1);
11551        let msg = &v["messages"][0];
11552        assert_eq!(msg["title"], "ping");
11553        // `from` is the resolved sender — `handle_notify` calls
11554        // `identity::resolve_agent_id(None, mcp_client)` which synthesizes
11555        // `ai:<client>@<host>:pid-N` when only `mcp_client` is set. We
11556        // accept both the bare and synthesized forms.
11557        let from = msg["from"].as_str().unwrap();
11558        assert!(
11559            from == "alice" || from.starts_with("ai:alice@"),
11560            "unexpected sender: {from}",
11561        );
11562        assert_eq!(msg["read"], false);
11563    }
11564
11565    /// `unread_only=true` filter omits already-read messages. We bump
11566    /// `access_count` directly on the seeded row so the filter has
11567    /// something to skip.
11568    #[tokio::test]
11569    async fn h8b_get_inbox_unread_only_filter_excludes_read() {
11570        let state = test_state();
11571        // Seed two messages — one read, one unread — directly via db::insert.
11572        {
11573            let lock = state.lock().await;
11574            let now = Utc::now().to_rfc3339();
11575            let unread = Memory {
11576                id: Uuid::new_v4().to_string(),
11577                tier: Tier::Mid,
11578                namespace: "_messages/alice".into(),
11579                title: "unread".into(),
11580                content: "u".into(),
11581                tags: vec!["_message".into()],
11582                priority: 5,
11583                confidence: 1.0,
11584                source: "notify".into(),
11585                access_count: 0,
11586                created_at: now.clone(),
11587                updated_at: now.clone(),
11588                last_accessed_at: None,
11589                expires_at: None,
11590                metadata: serde_json::json!({"agent_id": "bob"}),
11591            };
11592            let read = Memory {
11593                id: Uuid::new_v4().to_string(),
11594                tier: Tier::Mid,
11595                namespace: "_messages/alice".into(),
11596                title: "read".into(),
11597                content: "r".into(),
11598                tags: vec!["_message".into()],
11599                priority: 5,
11600                confidence: 1.0,
11601                source: "notify".into(),
11602                access_count: 5,
11603                created_at: now.clone(),
11604                updated_at: now,
11605                last_accessed_at: None,
11606                expires_at: None,
11607                metadata: serde_json::json!({"agent_id": "bob"}),
11608            };
11609            db::insert(&lock.0, &unread).unwrap();
11610            db::insert(&lock.0, &read).unwrap();
11611        }
11612
11613        let app = Router::new()
11614            .route("/api/v1/inbox", axum::routing::get(get_inbox))
11615            .with_state(test_app_state(state));
11616        let resp = app
11617            .oneshot(
11618                axum::http::Request::builder()
11619                    .uri("/api/v1/inbox?agent_id=alice&unread_only=true")
11620                    .body(Body::empty())
11621                    .unwrap(),
11622            )
11623            .await
11624            .unwrap();
11625        assert_eq!(resp.status(), StatusCode::OK);
11626        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11627            .await
11628            .unwrap();
11629        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11630        assert_eq!(v["count"], 1);
11631        assert_eq!(v["messages"][0]["title"], "unread");
11632        assert_eq!(v["unread_only"], true);
11633    }
11634
11635    /// `limit` query param caps the returned list. Insert 3, ask for 2.
11636    #[tokio::test]
11637    async fn h8b_get_inbox_limit_clamps_returned_count() {
11638        let state = test_state();
11639        {
11640            let lock = state.lock().await;
11641            let now = Utc::now().to_rfc3339();
11642            for i in 0..3 {
11643                let mem = Memory {
11644                    id: Uuid::new_v4().to_string(),
11645                    tier: Tier::Mid,
11646                    namespace: "_messages/alice".into(),
11647                    title: format!("msg-{i}"),
11648                    content: "c".into(),
11649                    tags: vec!["_message".into()],
11650                    priority: 5,
11651                    confidence: 1.0,
11652                    source: "notify".into(),
11653                    access_count: 0,
11654                    created_at: now.clone(),
11655                    updated_at: now.clone(),
11656                    last_accessed_at: None,
11657                    expires_at: None,
11658                    metadata: serde_json::json!({"agent_id": "carol"}),
11659                };
11660                db::insert(&lock.0, &mem).unwrap();
11661            }
11662        }
11663
11664        let app = Router::new()
11665            .route("/api/v1/inbox", axum::routing::get(get_inbox))
11666            .with_state(test_app_state(state));
11667        let resp = app
11668            .oneshot(
11669                axum::http::Request::builder()
11670                    .uri("/api/v1/inbox?agent_id=alice&limit=2")
11671                    .body(Body::empty())
11672                    .unwrap(),
11673            )
11674            .await
11675            .unwrap();
11676        assert_eq!(resp.status(), StatusCode::OK);
11677        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11678            .await
11679            .unwrap();
11680        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11681        assert_eq!(v["count"], 2);
11682    }
11683
11684    /// Invalid `agent_id` (illegal char) on the query string is rejected
11685    /// upstream by `resolve_caller_agent_id`.
11686    #[tokio::test]
11687    async fn h8b_get_inbox_invalid_agent_id_rejected() {
11688        let state = test_state();
11689        let app = Router::new()
11690            .route("/api/v1/inbox", axum::routing::get(get_inbox))
11691            .with_state(test_app_state(state));
11692
11693        let resp = app
11694            .oneshot(
11695                axum::http::Request::builder()
11696                    .uri("/api/v1/inbox?agent_id=bad%20agent")
11697                    .body(Body::empty())
11698                    .unwrap(),
11699            )
11700            .await
11701            .unwrap();
11702        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11703    }
11704
11705    // ---- session_start (POST /api/v1/session/start) ----
11706
11707    /// Happy path with a valid agent_id: stamps a `session_id` and echoes
11708    /// the agent_id back.
11709    #[tokio::test]
11710    async fn h8b_session_start_with_valid_agent_id_echoes() {
11711        let state = test_state();
11712        let app = Router::new()
11713            .route("/api/v1/session/start", axum_post(session_start))
11714            .with_state(state);
11715
11716        let body = serde_json::json!({"agent_id": "alice"});
11717        let resp = app
11718            .oneshot(
11719                axum::http::Request::builder()
11720                    .uri("/api/v1/session/start")
11721                    .method("POST")
11722                    .header("content-type", "application/json")
11723                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11724                    .unwrap(),
11725            )
11726            .await
11727            .unwrap();
11728        assert_eq!(resp.status(), StatusCode::OK);
11729        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11730            .await
11731            .unwrap();
11732        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11733        assert!(v["session_id"].as_str().is_some());
11734        assert_eq!(v["agent_id"], "alice");
11735    }
11736
11737    /// `namespace` filter narrows the recent-context preload to that ns.
11738    #[tokio::test]
11739    async fn h8b_session_start_namespace_filter() {
11740        let state = test_state();
11741        // Seed two memories, one in `target-ns` and one elsewhere.
11742        {
11743            let lock = state.lock().await;
11744            let now = Utc::now().to_rfc3339();
11745            for (ns, title) in [("target-ns", "in-scope"), ("other-ns", "out")] {
11746                let mem = Memory {
11747                    id: Uuid::new_v4().to_string(),
11748                    tier: Tier::Long,
11749                    namespace: ns.into(),
11750                    title: title.into(),
11751                    content: "body".into(),
11752                    tags: vec![],
11753                    priority: 5,
11754                    confidence: 1.0,
11755                    source: "api".into(),
11756                    access_count: 0,
11757                    created_at: now.clone(),
11758                    updated_at: now.clone(),
11759                    last_accessed_at: None,
11760                    expires_at: None,
11761                    metadata: serde_json::json!({"agent_id": "alice"}),
11762                };
11763                db::insert(&lock.0, &mem).unwrap();
11764            }
11765        }
11766
11767        let app = Router::new()
11768            .route("/api/v1/session/start", axum_post(session_start))
11769            .with_state(state);
11770        let body = serde_json::json!({"namespace": "target-ns", "limit": 5});
11771        let resp = app
11772            .oneshot(
11773                axum::http::Request::builder()
11774                    .uri("/api/v1/session/start")
11775                    .method("POST")
11776                    .header("content-type", "application/json")
11777                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11778                    .unwrap(),
11779            )
11780            .await
11781            .unwrap();
11782        assert_eq!(resp.status(), StatusCode::OK);
11783        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11784            .await
11785            .unwrap();
11786        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11787        // Only the target-ns memory is in the recent set.
11788        let mems = v["memories"].as_array().unwrap();
11789        assert_eq!(mems.len(), 1);
11790        assert_eq!(mems[0]["title"], "in-scope");
11791    }
11792
11793    /// session_start with no body fields still succeeds — agent_id is
11794    /// optional and the handler stamps a uuid session_id regardless.
11795    #[tokio::test]
11796    async fn h8b_session_start_returns_session_id_without_agent() {
11797        let state = test_state();
11798        let app = Router::new()
11799            .route("/api/v1/session/start", axum_post(session_start))
11800            .with_state(state);
11801        let body = serde_json::json!({});
11802        let resp = app
11803            .oneshot(
11804                axum::http::Request::builder()
11805                    .uri("/api/v1/session/start")
11806                    .method("POST")
11807                    .header("content-type", "application/json")
11808                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11809                    .unwrap(),
11810            )
11811            .await
11812            .unwrap();
11813        assert_eq!(resp.status(), StatusCode::OK);
11814        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11815            .await
11816            .unwrap();
11817        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11818        // session_id present; uuid v4 is 36 chars long.
11819        let sid = v["session_id"].as_str().unwrap();
11820        assert_eq!(sid.len(), 36);
11821        // No explicit agent_id field is added when caller didn't supply one.
11822        assert!(v.get("agent_id").is_none() || v["agent_id"].is_null());
11823        assert_eq!(v["mode"], "session_start");
11824    }
11825
11826    /// session_start preloads recent memories from all namespaces when no
11827    /// `namespace` filter is supplied. Verifies the include-all branch.
11828    #[tokio::test]
11829    async fn h8b_session_start_preloads_recent_context() {
11830        let state = test_state();
11831        {
11832            let lock = state.lock().await;
11833            let now = Utc::now().to_rfc3339();
11834            let mem = Memory {
11835                id: Uuid::new_v4().to_string(),
11836                tier: Tier::Long,
11837                namespace: "global".into(),
11838                title: "preload-me".into(),
11839                content: "context".into(),
11840                tags: vec![],
11841                priority: 5,
11842                confidence: 1.0,
11843                source: "api".into(),
11844                access_count: 0,
11845                created_at: now.clone(),
11846                updated_at: now,
11847                last_accessed_at: None,
11848                expires_at: None,
11849                metadata: serde_json::json!({"agent_id": "alice"}),
11850            };
11851            db::insert(&lock.0, &mem).unwrap();
11852        }
11853
11854        let app = Router::new()
11855            .route("/api/v1/session/start", axum_post(session_start))
11856            .with_state(state);
11857        let body = serde_json::json!({"limit": 50});
11858        let resp = app
11859            .oneshot(
11860                axum::http::Request::builder()
11861                    .uri("/api/v1/session/start")
11862                    .method("POST")
11863                    .header("content-type", "application/json")
11864                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
11865                    .unwrap(),
11866            )
11867            .await
11868            .unwrap();
11869        assert_eq!(resp.status(), StatusCode::OK);
11870        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11871            .await
11872            .unwrap();
11873        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11874        let mems = v["memories"].as_array().unwrap();
11875        assert!(
11876            mems.iter().any(|m| m["title"] == "preload-me"),
11877            "session_start must preload recent memories",
11878        );
11879    }
11880    // ========================================================================
11881    // W8/H8c — handlers.rs gap-closing for agents/pending/consolidate.
11882    //
11883    // Coverage targets:
11884    //   list_agents, register_agent, list_pending, approve_pending,
11885    //   reject_pending, consolidate_memories, detect_contradictions,
11886    //   get_capabilities.
11887    //
11888    // All tests drive the real Axum handler via `tower::ServiceExt::oneshot`
11889    // and assert on (status, body) to hit handler arms — including the
11890    // post-validation success paths that earlier W7 tests skipped.
11891    // ========================================================================
11892
11893    // ---- list_agents (GET /api/v1/agents) ----------------------------------
11894
11895    #[tokio::test]
11896    async fn http_list_agents_empty_returns_zero_count() {
11897        // Empty `_agents` namespace: count=0, agents=[].
11898        let state = test_state();
11899        let app = Router::new()
11900            .route("/api/v1/agents", axum_get(list_agents))
11901            .with_state(state);
11902        let resp = app
11903            .oneshot(
11904                axum::http::Request::builder()
11905                    .uri("/api/v1/agents")
11906                    .body(Body::empty())
11907                    .unwrap(),
11908            )
11909            .await
11910            .unwrap();
11911        assert_eq!(resp.status(), StatusCode::OK);
11912        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11913            .await
11914            .unwrap();
11915        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11916        assert_eq!(v["count"], 0);
11917        assert_eq!(v["agents"].as_array().unwrap().len(), 0);
11918    }
11919
11920    #[tokio::test]
11921    async fn http_list_agents_returns_registered_rows() {
11922        // Pre-register two agents directly via db::register_agent and
11923        // confirm both surface through the list handler.
11924        let state = test_state();
11925        {
11926            let lock = state.lock().await;
11927            db::register_agent(&lock.0, "alice", "human", &["read".into(), "write".into()])
11928                .unwrap();
11929            db::register_agent(&lock.0, "bob", "ai:claude-opus-4.7", &["recall".into()]).unwrap();
11930        }
11931        let app = Router::new()
11932            .route("/api/v1/agents", axum_get(list_agents))
11933            .with_state(state);
11934        let resp = app
11935            .oneshot(
11936                axum::http::Request::builder()
11937                    .uri("/api/v1/agents")
11938                    .body(Body::empty())
11939                    .unwrap(),
11940            )
11941            .await
11942            .unwrap();
11943        assert_eq!(resp.status(), StatusCode::OK);
11944        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11945            .await
11946            .unwrap();
11947        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11948        assert_eq!(v["count"], 2);
11949        let agents = v["agents"].as_array().unwrap();
11950        let ids: Vec<&str> = agents
11951            .iter()
11952            .filter_map(|a| a["agent_id"].as_str())
11953            .collect();
11954        assert!(ids.contains(&"alice"));
11955        assert!(ids.contains(&"bob"));
11956    }
11957
11958    #[tokio::test]
11959    async fn http_list_agents_includes_types_and_capabilities() {
11960        // The serialized agent rows must surface agent_type AND the
11961        // capability list back to the caller — not just agent_id.
11962        let state = test_state();
11963        {
11964            let lock = state.lock().await;
11965            db::register_agent(
11966                &lock.0,
11967                "alpha",
11968                "ai:claude-opus-4.7",
11969                &["read".into(), "store".into(), "recall".into()],
11970            )
11971            .unwrap();
11972        }
11973        let app = Router::new()
11974            .route("/api/v1/agents", axum_get(list_agents))
11975            .with_state(state);
11976        let resp = app
11977            .oneshot(
11978                axum::http::Request::builder()
11979                    .uri("/api/v1/agents")
11980                    .body(Body::empty())
11981                    .unwrap(),
11982            )
11983            .await
11984            .unwrap();
11985        assert_eq!(resp.status(), StatusCode::OK);
11986        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11987            .await
11988            .unwrap();
11989        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11990        let agents = v["agents"].as_array().unwrap();
11991        assert_eq!(agents.len(), 1);
11992        let a = &agents[0];
11993        assert_eq!(a["agent_id"], "alpha");
11994        assert_eq!(a["agent_type"], "ai:claude-opus-4.7");
11995        let caps = a["capabilities"].as_array().unwrap();
11996        assert_eq!(caps.len(), 3);
11997        let cap_strs: Vec<&str> = caps.iter().filter_map(|c| c.as_str()).collect();
11998        assert!(cap_strs.contains(&"read"));
11999        assert!(cap_strs.contains(&"store"));
12000        assert!(cap_strs.contains(&"recall"));
12001    }
12002
12003    // ---- register_agent (POST /api/v1/agents) ------------------------------
12004
12005    #[tokio::test]
12006    async fn http_register_agent_happy_path_returns_created() {
12007        let state = test_state();
12008        let app = Router::new()
12009            .route("/api/v1/agents", axum_post(register_agent))
12010            .with_state(test_app_state(state.clone()));
12011        let body = serde_json::json!({
12012            "agent_id": "alice",
12013            "agent_type": "human",
12014            "capabilities": ["read", "write"]
12015        });
12016        let resp = app
12017            .oneshot(
12018                axum::http::Request::builder()
12019                    .uri("/api/v1/agents")
12020                    .method("POST")
12021                    .header("content-type", "application/json")
12022                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12023                    .unwrap(),
12024            )
12025            .await
12026            .unwrap();
12027        assert_eq!(resp.status(), StatusCode::CREATED);
12028        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12029            .await
12030            .unwrap();
12031        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12032        assert_eq!(v["registered"], true);
12033        assert_eq!(v["agent_id"], "alice");
12034        assert_eq!(v["agent_type"], "human");
12035        // Row landed in `_agents` namespace.
12036        let lock = state.lock().await;
12037        let agents = db::list_agents(&lock.0).unwrap();
12038        assert_eq!(agents.len(), 1);
12039        assert_eq!(agents[0].agent_id, "alice");
12040    }
12041
12042    #[tokio::test]
12043    async fn http_register_agent_missing_agent_type_400() {
12044        // Missing `agent_type` on the JSON body — Axum's Json extractor
12045        // rejects with 4xx (422 from serde-error wrapping).
12046        let state = test_state();
12047        let app = Router::new()
12048            .route("/api/v1/agents", axum_post(register_agent))
12049            .with_state(test_app_state(state));
12050        let body = serde_json::json!({
12051            "agent_id": "alice"
12052            // no agent_type, no capabilities
12053        });
12054        let resp = app
12055            .oneshot(
12056                axum::http::Request::builder()
12057                    .uri("/api/v1/agents")
12058                    .method("POST")
12059                    .header("content-type", "application/json")
12060                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12061                    .unwrap(),
12062            )
12063            .await
12064            .unwrap();
12065        assert!(
12066            resp.status().is_client_error(),
12067            "expected 4xx for missing agent_type, got {}",
12068            resp.status()
12069        );
12070    }
12071
12072    #[tokio::test]
12073    async fn http_register_agent_invalid_agent_id_with_space_400() {
12074        // validate_agent_id rejects spaces.
12075        let state = test_state();
12076        let app = Router::new()
12077            .route("/api/v1/agents", axum_post(register_agent))
12078            .with_state(test_app_state(state));
12079        let body = serde_json::json!({
12080            "agent_id": "bad agent",
12081            "agent_type": "human",
12082            "capabilities": []
12083        });
12084        let resp = app
12085            .oneshot(
12086                axum::http::Request::builder()
12087                    .uri("/api/v1/agents")
12088                    .method("POST")
12089                    .header("content-type", "application/json")
12090                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12091                    .unwrap(),
12092            )
12093            .await
12094            .unwrap();
12095        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12096    }
12097
12098    #[tokio::test]
12099    async fn http_register_agent_duplicate_register_idempotent_preserves_registered_at() {
12100        // Re-registering the same agent_id is allowed (UPSERT-style on
12101        // (namespace, title)). Both calls return 201; registered_at is
12102        // preserved across the second call (db::register_agent reads it back).
12103        let state = test_state();
12104        let app = Router::new()
12105            .route("/api/v1/agents", axum_post(register_agent))
12106            .with_state(test_app_state(state.clone()));
12107        let body = serde_json::json!({
12108            "agent_id": "twice",
12109            "agent_type": "human",
12110            "capabilities": ["read"]
12111        });
12112        let r1 = app
12113            .clone()
12114            .oneshot(
12115                axum::http::Request::builder()
12116                    .uri("/api/v1/agents")
12117                    .method("POST")
12118                    .header("content-type", "application/json")
12119                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12120                    .unwrap(),
12121            )
12122            .await
12123            .unwrap();
12124        assert_eq!(r1.status(), StatusCode::CREATED);
12125        let r2 = app
12126            .oneshot(
12127                axum::http::Request::builder()
12128                    .uri("/api/v1/agents")
12129                    .method("POST")
12130                    .header("content-type", "application/json")
12131                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12132                    .unwrap(),
12133            )
12134            .await
12135            .unwrap();
12136        assert_eq!(r2.status(), StatusCode::CREATED);
12137        // Only one row for this agent_id (LWW on title=agent:twice).
12138        let lock = state.lock().await;
12139        let agents = db::list_agents(&lock.0).unwrap();
12140        let twice: Vec<_> = agents.iter().filter(|a| a.agent_id == "twice").collect();
12141        assert_eq!(
12142            twice.len(),
12143            1,
12144            "duplicate register must collapse to one row"
12145        );
12146    }
12147
12148    #[tokio::test]
12149    async fn http_register_agent_capabilities_array_preserved() {
12150        // The full `capabilities` array round-trips through register +
12151        // list. Specifically: order-insensitive coverage of all members.
12152        let state = test_state();
12153        let app = Router::new()
12154            .route("/api/v1/agents", axum_post(register_agent))
12155            .with_state(test_app_state(state.clone()));
12156        let body = serde_json::json!({
12157            "agent_id": "capper",
12158            "agent_type": "ai:claude-opus-4.7",
12159            "capabilities": ["search", "store", "recall", "consolidate"]
12160        });
12161        let resp = app
12162            .oneshot(
12163                axum::http::Request::builder()
12164                    .uri("/api/v1/agents")
12165                    .method("POST")
12166                    .header("content-type", "application/json")
12167                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12168                    .unwrap(),
12169            )
12170            .await
12171            .unwrap();
12172        assert_eq!(resp.status(), StatusCode::CREATED);
12173        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12174            .await
12175            .unwrap();
12176        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12177        let echoed = v["capabilities"].as_array().unwrap();
12178        assert_eq!(echoed.len(), 4);
12179        // And persisted shape matches.
12180        let lock = state.lock().await;
12181        let agents = db::list_agents(&lock.0).unwrap();
12182        let me = agents.iter().find(|a| a.agent_id == "capper").unwrap();
12183        assert_eq!(me.capabilities.len(), 4);
12184        assert!(me.capabilities.contains(&"search".to_string()));
12185        assert!(me.capabilities.contains(&"store".to_string()));
12186        assert!(me.capabilities.contains(&"recall".to_string()));
12187        assert!(me.capabilities.contains(&"consolidate".to_string()));
12188    }
12189
12190    // ---- list_pending (GET /api/v1/pending) --------------------------------
12191
12192    #[tokio::test]
12193    async fn http_list_pending_with_pending_actions_returns_them() {
12194        // Queue two pending actions and confirm both surface.
12195        use crate::models::GovernedAction;
12196        let state = test_state();
12197        {
12198            let lock = state.lock().await;
12199            db::queue_pending_action(
12200                &lock.0,
12201                GovernedAction::Store,
12202                "ns-a",
12203                None,
12204                "alice",
12205                &serde_json::json!({"title": "first", "content": "c1"}),
12206            )
12207            .unwrap();
12208            db::queue_pending_action(
12209                &lock.0,
12210                GovernedAction::Store,
12211                "ns-b",
12212                None,
12213                "bob",
12214                &serde_json::json!({"title": "second", "content": "c2"}),
12215            )
12216            .unwrap();
12217        }
12218        let app = Router::new()
12219            .route("/api/v1/pending", axum_get(list_pending))
12220            .with_state(state);
12221        let resp = app
12222            .oneshot(
12223                axum::http::Request::builder()
12224                    .uri("/api/v1/pending")
12225                    .body(Body::empty())
12226                    .unwrap(),
12227            )
12228            .await
12229            .unwrap();
12230        assert_eq!(resp.status(), StatusCode::OK);
12231        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12232            .await
12233            .unwrap();
12234        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12235        assert_eq!(v["count"], 2);
12236        assert_eq!(v["pending"].as_array().unwrap().len(), 2);
12237    }
12238
12239    #[tokio::test]
12240    async fn http_list_pending_filters_by_status_pending() {
12241        use crate::models::GovernedAction;
12242        let state = test_state();
12243        let kept_id = {
12244            let lock = state.lock().await;
12245            // One pending action that stays pending.
12246            let id = db::queue_pending_action(
12247                &lock.0,
12248                GovernedAction::Store,
12249                "ns-keep",
12250                None,
12251                "alice",
12252                &serde_json::json!({"title": "stay", "content": "x"}),
12253            )
12254            .unwrap();
12255            // One that we mark rejected.
12256            let other = db::queue_pending_action(
12257                &lock.0,
12258                GovernedAction::Store,
12259                "ns-reject",
12260                None,
12261                "alice",
12262                &serde_json::json!({"title": "out", "content": "x"}),
12263            )
12264            .unwrap();
12265            db::decide_pending_action(&lock.0, &other, false, "alice").unwrap();
12266            id
12267        };
12268        let app = Router::new()
12269            .route("/api/v1/pending", axum_get(list_pending))
12270            .with_state(state);
12271        let resp = app
12272            .oneshot(
12273                axum::http::Request::builder()
12274                    .uri("/api/v1/pending?status=pending")
12275                    .body(Body::empty())
12276                    .unwrap(),
12277            )
12278            .await
12279            .unwrap();
12280        assert_eq!(resp.status(), StatusCode::OK);
12281        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12282            .await
12283            .unwrap();
12284        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12285        let items = v["pending"].as_array().unwrap();
12286        assert_eq!(items.len(), 1);
12287        assert_eq!(items[0]["id"], kept_id);
12288        assert_eq!(items[0]["status"], "pending");
12289    }
12290
12291    #[tokio::test]
12292    async fn http_list_pending_filters_by_status_rejected() {
12293        use crate::models::GovernedAction;
12294        let state = test_state();
12295        {
12296            let lock = state.lock().await;
12297            let id = db::queue_pending_action(
12298                &lock.0,
12299                GovernedAction::Store,
12300                "ns-r",
12301                None,
12302                "alice",
12303                &serde_json::json!({"title": "rejected", "content": "x"}),
12304            )
12305            .unwrap();
12306            db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
12307            // Pending one to verify it doesn't leak through.
12308            db::queue_pending_action(
12309                &lock.0,
12310                GovernedAction::Store,
12311                "ns-p",
12312                None,
12313                "alice",
12314                &serde_json::json!({"title": "pending", "content": "x"}),
12315            )
12316            .unwrap();
12317        }
12318        let app = Router::new()
12319            .route("/api/v1/pending", axum_get(list_pending))
12320            .with_state(state);
12321        let resp = app
12322            .oneshot(
12323                axum::http::Request::builder()
12324                    .uri("/api/v1/pending?status=rejected&limit=10")
12325                    .body(Body::empty())
12326                    .unwrap(),
12327            )
12328            .await
12329            .unwrap();
12330        assert_eq!(resp.status(), StatusCode::OK);
12331        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12332            .await
12333            .unwrap();
12334        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12335        let items = v["pending"].as_array().unwrap();
12336        assert_eq!(items.len(), 1);
12337        assert_eq!(items[0]["status"], "rejected");
12338    }
12339
12340    #[tokio::test]
12341    async fn http_list_pending_limit_clamped_to_1000() {
12342        // Pass a deliberately-large limit; handler clamps to 1000 but
12343        // still returns 200 (we just verify the ceiling path executes).
12344        let state = test_state();
12345        let app = Router::new()
12346            .route("/api/v1/pending", axum_get(list_pending))
12347            .with_state(state);
12348        let resp = app
12349            .oneshot(
12350                axum::http::Request::builder()
12351                    .uri("/api/v1/pending?limit=99999")
12352                    .body(Body::empty())
12353                    .unwrap(),
12354            )
12355            .await
12356            .unwrap();
12357        assert_eq!(resp.status(), StatusCode::OK);
12358    }
12359
12360    // ---- approve_pending (POST /api/v1/pending/{id}/approve) ---------------
12361
12362    #[tokio::test]
12363    async fn http_approve_pending_happy_path_executes_store() {
12364        // Queue a Store payload, approve it, expect 200 + executed=true +
12365        // a memory_id we can fetch back.
12366        use crate::models::GovernedAction;
12367        let state = test_state();
12368        let now_rfc = Utc::now().to_rfc3339();
12369        let pending_id = {
12370            let lock = state.lock().await;
12371            db::queue_pending_action(
12372                &lock.0,
12373                GovernedAction::Store,
12374                "approve-ns",
12375                None,
12376                "alice",
12377                &serde_json::json!({
12378                    "id": Uuid::new_v4().to_string(),
12379                    "tier": "long",
12380                    "namespace": "approve-ns",
12381                    "title": "approved-store",
12382                    "content": "executed via approval",
12383                    "tags": [],
12384                    "priority": 5,
12385                    "confidence": 1.0,
12386                    "source": "api",
12387                    "access_count": 0,
12388                    "created_at": now_rfc,
12389                    "updated_at": now_rfc,
12390                    "metadata": {}
12391                }),
12392            )
12393            .unwrap()
12394        };
12395        let app = Router::new()
12396            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12397            .with_state(test_app_state(state.clone()));
12398        let resp = app
12399            .oneshot(
12400                axum::http::Request::builder()
12401                    .uri(format!("/api/v1/pending/{pending_id}/approve"))
12402                    .method("POST")
12403                    .header("x-agent-id", "approver-alice")
12404                    .body(Body::empty())
12405                    .unwrap(),
12406            )
12407            .await
12408            .unwrap();
12409        assert_eq!(resp.status(), StatusCode::OK);
12410        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12411            .await
12412            .unwrap();
12413        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12414        assert_eq!(v["approved"], true);
12415        assert_eq!(v["executed"], true);
12416        assert_eq!(v["decided_by"], "approver-alice");
12417        // Status is now 'approved' in the row.
12418        let lock = state.lock().await;
12419        let pa = db::get_pending_action(&lock.0, &pending_id)
12420            .unwrap()
12421            .unwrap();
12422        assert_eq!(pa.status, "approved");
12423        assert_eq!(pa.decided_by.as_deref(), Some("approver-alice"));
12424    }
12425
12426    #[tokio::test]
12427    async fn http_approve_pending_invalid_id_format_400() {
12428        // validate_id rejects ids with embedded control chars — handler
12429        // returns 400 BEFORE touching the DB. We use %01 (SOH) which
12430        // is_clean_string flags as invalid.
12431        let state = test_state();
12432        let app = Router::new()
12433            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12434            .with_state(test_app_state(state));
12435        let resp = app
12436            .oneshot(
12437                axum::http::Request::builder()
12438                    .uri("/api/v1/pending/bad%01id/approve")
12439                    .method("POST")
12440                    .header("x-agent-id", "alice")
12441                    .body(Body::empty())
12442                    .unwrap(),
12443            )
12444            .await
12445            .unwrap();
12446        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12447    }
12448
12449    #[tokio::test]
12450    async fn http_approve_pending_already_approved_is_rejected() {
12451        // Once an action is decided, a follow-up approve must NOT execute
12452        // again — it returns FORBIDDEN with `approve rejected: already decided`.
12453        use crate::models::GovernedAction;
12454        let state = test_state();
12455        let pid = {
12456            let lock = state.lock().await;
12457            let id = db::queue_pending_action(
12458                &lock.0,
12459                GovernedAction::Store,
12460                "double-approve",
12461                None,
12462                "alice",
12463                &serde_json::json!({
12464                    "tier": "long",
12465                    "namespace": "double-approve",
12466                    "title": "store",
12467                    "content": "x",
12468                    "tags": [], "priority": 5, "confidence": 1.0,
12469                    "source": "api", "metadata": {}
12470                }),
12471            )
12472            .unwrap();
12473            db::decide_pending_action(&lock.0, &id, true, "alice").unwrap();
12474            id
12475        };
12476        let app = Router::new()
12477            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12478            .with_state(test_app_state(state));
12479        let resp = app
12480            .oneshot(
12481                axum::http::Request::builder()
12482                    .uri(format!("/api/v1/pending/{pid}/approve"))
12483                    .method("POST")
12484                    .header("x-agent-id", "alice")
12485                    .body(Body::empty())
12486                    .unwrap(),
12487            )
12488            .await
12489            .unwrap();
12490        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
12491        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12492            .await
12493            .unwrap();
12494        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12495        let err = v["error"].as_str().unwrap_or("");
12496        assert!(
12497            err.contains("already decided") || err.contains("rejected"),
12498            "expected already-decided message, got {err}"
12499        );
12500    }
12501
12502    #[tokio::test]
12503    async fn http_approve_pending_executor_records_decided_by() {
12504        // After a successful approve the row's decided_by is the same id
12505        // we passed via X-Agent-Id, not the requester. This is the
12506        // executor-records-approval invariant.
12507        use crate::models::GovernedAction;
12508        let state = test_state();
12509        let now_rfc = Utc::now().to_rfc3339();
12510        let pid = {
12511            let lock = state.lock().await;
12512            db::queue_pending_action(
12513                &lock.0,
12514                GovernedAction::Store,
12515                "executor-ns",
12516                None,
12517                "requester-bob",
12518                &serde_json::json!({
12519                    "id": Uuid::new_v4().to_string(),
12520                    "tier": "long",
12521                    "namespace": "executor-ns",
12522                    "title": "e",
12523                    "content": "y",
12524                    "tags": [], "priority": 5, "confidence": 1.0,
12525                    "source": "api",
12526                    "access_count": 0,
12527                    "created_at": now_rfc,
12528                    "updated_at": now_rfc,
12529                    "metadata": {}
12530                }),
12531            )
12532            .unwrap()
12533        };
12534        let app = Router::new()
12535            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12536            .with_state(test_app_state(state.clone()));
12537        let resp = app
12538            .oneshot(
12539                axum::http::Request::builder()
12540                    .uri(format!("/api/v1/pending/{pid}/approve"))
12541                    .method("POST")
12542                    .header("x-agent-id", "executor-claude")
12543                    .body(Body::empty())
12544                    .unwrap(),
12545            )
12546            .await
12547            .unwrap();
12548        assert_eq!(resp.status(), StatusCode::OK);
12549        let lock = state.lock().await;
12550        let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
12551        assert_eq!(pa.requested_by, "requester-bob");
12552        assert_eq!(pa.decided_by.as_deref(), Some("executor-claude"));
12553        assert_eq!(pa.status, "approved");
12554    }
12555
12556    #[tokio::test]
12557    async fn http_approve_pending_returns_memory_id_for_store_payload() {
12558        // happy-path Store: the response carries a memory_id and that
12559        // memory is queryable via db::get.
12560        use crate::models::GovernedAction;
12561        let state = test_state();
12562        let now_rfc = Utc::now().to_rfc3339();
12563        let pid = {
12564            let lock = state.lock().await;
12565            db::queue_pending_action(
12566                &lock.0,
12567                GovernedAction::Store,
12568                "executed-write",
12569                None,
12570                "alice",
12571                &serde_json::json!({
12572                    "id": Uuid::new_v4().to_string(),
12573                    "tier": "long",
12574                    "namespace": "executed-write",
12575                    "title": "executed-mem",
12576                    "content": "this exists after approval",
12577                    "tags": [], "priority": 5, "confidence": 1.0,
12578                    "source": "api",
12579                    "access_count": 0,
12580                    "created_at": now_rfc,
12581                    "updated_at": now_rfc,
12582                    "metadata": {}
12583                }),
12584            )
12585            .unwrap()
12586        };
12587        let app = Router::new()
12588            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12589            .with_state(test_app_state(state.clone()));
12590        let resp = app
12591            .oneshot(
12592                axum::http::Request::builder()
12593                    .uri(format!("/api/v1/pending/{pid}/approve"))
12594                    .method("POST")
12595                    .header("x-agent-id", "alice")
12596                    .body(Body::empty())
12597                    .unwrap(),
12598            )
12599            .await
12600            .unwrap();
12601        assert_eq!(resp.status(), StatusCode::OK);
12602        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12603            .await
12604            .unwrap();
12605        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12606        let mem_id = v["memory_id"].as_str().expect("memory_id present");
12607        let lock = state.lock().await;
12608        let mem = db::get(&lock.0, mem_id).unwrap().expect("memory exists");
12609        assert_eq!(mem.title, "executed-mem");
12610        assert_eq!(mem.namespace, "executed-write");
12611    }
12612
12613    // ---- reject_pending (POST /api/v1/pending/{id}/reject) -----------------
12614
12615    #[tokio::test]
12616    async fn http_reject_pending_happy_path_marks_rejected_no_execution() {
12617        // Reject path: row goes to status='rejected', decided_by stamped,
12618        // and NO underlying memory is created.
12619        use crate::models::GovernedAction;
12620        let state = test_state();
12621        let pid = {
12622            let lock = state.lock().await;
12623            db::queue_pending_action(
12624                &lock.0,
12625                GovernedAction::Store,
12626                "reject-ns",
12627                None,
12628                "alice",
12629                &serde_json::json!({
12630                    "tier": "long",
12631                    "namespace": "reject-ns",
12632                    "title": "blocked",
12633                    "content": "must not be created",
12634                    "tags": [], "priority": 5, "confidence": 1.0,
12635                    "source": "api", "metadata": {}
12636                }),
12637            )
12638            .unwrap()
12639        };
12640        let app = Router::new()
12641            .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12642            .with_state(test_app_state(state.clone()));
12643        let resp = app
12644            .oneshot(
12645                axum::http::Request::builder()
12646                    .uri(format!("/api/v1/pending/{pid}/reject"))
12647                    .method("POST")
12648                    .header("x-agent-id", "rejector-alice")
12649                    .body(Body::empty())
12650                    .unwrap(),
12651            )
12652            .await
12653            .unwrap();
12654        assert_eq!(resp.status(), StatusCode::OK);
12655        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12656            .await
12657            .unwrap();
12658        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12659        assert_eq!(v["rejected"], true);
12660        assert_eq!(v["decided_by"], "rejector-alice");
12661        let lock = state.lock().await;
12662        let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
12663        assert_eq!(pa.status, "rejected");
12664        // Confirm no memory landed in `reject-ns`.
12665        let rows = db::list(
12666            &lock.0,
12667            Some("reject-ns"),
12668            None,
12669            10,
12670            0,
12671            None,
12672            None,
12673            None,
12674            None,
12675            None,
12676        )
12677        .unwrap();
12678        assert!(
12679            rows.is_empty(),
12680            "rejection must not execute the queued payload"
12681        );
12682    }
12683
12684    #[tokio::test]
12685    async fn http_reject_pending_already_rejected_returns_404() {
12686        // Once decided, decide_pending_action returns false; the handler
12687        // surfaces this as 404 ("not found or already decided").
12688        use crate::models::GovernedAction;
12689        let state = test_state();
12690        let pid = {
12691            let lock = state.lock().await;
12692            let id = db::queue_pending_action(
12693                &lock.0,
12694                GovernedAction::Store,
12695                "double-reject",
12696                None,
12697                "alice",
12698                &serde_json::json!({
12699                    "tier": "long",
12700                    "namespace": "double-reject",
12701                    "title": "x",
12702                    "content": "x",
12703                    "tags": [], "priority": 5, "confidence": 1.0,
12704                    "source": "api", "metadata": {}
12705                }),
12706            )
12707            .unwrap();
12708            db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
12709            id
12710        };
12711        let app = Router::new()
12712            .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12713            .with_state(test_app_state(state));
12714        let resp = app
12715            .oneshot(
12716                axum::http::Request::builder()
12717                    .uri(format!("/api/v1/pending/{pid}/reject"))
12718                    .method("POST")
12719                    .header("x-agent-id", "alice")
12720                    .body(Body::empty())
12721                    .unwrap(),
12722            )
12723            .await
12724            .unwrap();
12725        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
12726    }
12727
12728    #[tokio::test]
12729    async fn http_reject_pending_invalid_id_format_400() {
12730        // validate_id flags ids containing control chars; %01 hits that
12731        // arm and returns 400 before any DB lookup.
12732        let state = test_state();
12733        let app = Router::new()
12734            .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12735            .with_state(test_app_state(state));
12736        let resp = app
12737            .oneshot(
12738                axum::http::Request::builder()
12739                    .uri("/api/v1/pending/bad%01id/reject")
12740                    .method("POST")
12741                    .header("x-agent-id", "alice")
12742                    .body(Body::empty())
12743                    .unwrap(),
12744            )
12745            .await
12746            .unwrap();
12747        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12748    }
12749
12750    // ---- consolidate_memories (POST /api/v1/consolidate) -------------------
12751
12752    #[tokio::test]
12753    async fn http_consolidate_two_into_one_happy_path() {
12754        // Insert two memories, consolidate them, expect 201 with a new
12755        // memory id and the originals removed.
12756        let state = test_state();
12757        let now = Utc::now().to_rfc3339();
12758        let (id_a, id_b) = {
12759            let lock = state.lock().await;
12760            let mk = |title: &str| Memory {
12761                id: Uuid::new_v4().to_string(),
12762                tier: Tier::Long,
12763                namespace: "merge-ns".into(),
12764                title: title.into(),
12765                content: format!("body for {title}"),
12766                tags: vec![],
12767                priority: 5,
12768                confidence: 1.0,
12769                source: "test".into(),
12770                access_count: 0,
12771                created_at: now.clone(),
12772                updated_at: now.clone(),
12773                last_accessed_at: None,
12774                expires_at: None,
12775                metadata: serde_json::json!({"agent_id": "alice"}),
12776            };
12777            let a = db::insert(&lock.0, &mk("draft-a")).unwrap();
12778            let b = db::insert(&lock.0, &mk("draft-b")).unwrap();
12779            (a, b)
12780        };
12781        let app = Router::new()
12782            .route("/api/v1/consolidate", axum_post(consolidate_memories))
12783            .with_state(test_app_state(state.clone()));
12784        let body = serde_json::json!({
12785            "ids": [id_a, id_b],
12786            "title": "merged-result",
12787            "summary": "a merge of two drafts",
12788            "namespace": "merge-ns",
12789            "tier": "long"
12790        });
12791        let resp = app
12792            .oneshot(
12793                axum::http::Request::builder()
12794                    .uri("/api/v1/consolidate")
12795                    .method("POST")
12796                    .header("content-type", "application/json")
12797                    .header("x-agent-id", "consolidator")
12798                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12799                    .unwrap(),
12800            )
12801            .await
12802            .unwrap();
12803        assert_eq!(resp.status(), StatusCode::CREATED);
12804        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12805            .await
12806            .unwrap();
12807        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12808        assert_eq!(v["consolidated"], 2);
12809        let new_id = v["id"].as_str().unwrap();
12810        let lock = state.lock().await;
12811        let merged = db::get(&lock.0, new_id).unwrap().unwrap();
12812        assert_eq!(merged.title, "merged-result");
12813        assert_eq!(merged.namespace, "merge-ns");
12814        // Originals removed.
12815        assert!(db::get(&lock.0, &id_a).unwrap().is_none());
12816        assert!(db::get(&lock.0, &id_b).unwrap().is_none());
12817    }
12818
12819    #[tokio::test]
12820    async fn http_consolidate_single_id_400() {
12821        // validate_consolidate requires ≥2 ids — single-id calls are
12822        // rejected up front with 400.
12823        let state = test_state();
12824        let app = Router::new()
12825            .route("/api/v1/consolidate", axum_post(consolidate_memories))
12826            .with_state(test_app_state(state));
12827        let body = serde_json::json!({
12828            "ids": [Uuid::new_v4().to_string()],
12829            "title": "lone-merge",
12830            "summary": "only one source",
12831            "namespace": "merge-ns"
12832        });
12833        let resp = app
12834            .oneshot(
12835                axum::http::Request::builder()
12836                    .uri("/api/v1/consolidate")
12837                    .method("POST")
12838                    .header("content-type", "application/json")
12839                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12840                    .unwrap(),
12841            )
12842            .await
12843            .unwrap();
12844        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12845    }
12846
12847    #[tokio::test]
12848    async fn http_consolidate_invalid_namespace_400() {
12849        // Namespace with a space fails validate_namespace.
12850        let state = test_state();
12851        let app = Router::new()
12852            .route("/api/v1/consolidate", axum_post(consolidate_memories))
12853            .with_state(test_app_state(state));
12854        let body = serde_json::json!({
12855            "ids": [Uuid::new_v4().to_string(), Uuid::new_v4().to_string()],
12856            "title": "merge",
12857            "summary": "x",
12858            "namespace": "bad ns"
12859        });
12860        let resp = app
12861            .oneshot(
12862                axum::http::Request::builder()
12863                    .uri("/api/v1/consolidate")
12864                    .method("POST")
12865                    .header("content-type", "application/json")
12866                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12867                    .unwrap(),
12868            )
12869            .await
12870            .unwrap();
12871        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12872    }
12873
12874    #[tokio::test]
12875    async fn http_consolidate_invalid_agent_id_400() {
12876        // X-Agent-Id with a space → identity::resolve_http_agent_id error → 400.
12877        let state = test_state();
12878        let id_a = Uuid::new_v4().to_string();
12879        let id_b = Uuid::new_v4().to_string();
12880        let app = Router::new()
12881            .route("/api/v1/consolidate", axum_post(consolidate_memories))
12882            .with_state(test_app_state(state));
12883        let body = serde_json::json!({
12884            "ids": [id_a, id_b],
12885            "title": "merge",
12886            "summary": "x",
12887            "namespace": "merge-ns"
12888        });
12889        let resp = app
12890            .oneshot(
12891                axum::http::Request::builder()
12892                    .uri("/api/v1/consolidate")
12893                    .method("POST")
12894                    .header("content-type", "application/json")
12895                    .header("x-agent-id", "bad agent id")
12896                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12897                    .unwrap(),
12898            )
12899            .await
12900            .unwrap();
12901        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12902    }
12903
12904    #[tokio::test]
12905    async fn http_consolidate_max_id_count_cap_exceeded_400() {
12906        // validate_consolidate caps at 100 ids.
12907        let state = test_state();
12908        let ids: Vec<String> = (0..101).map(|_| Uuid::new_v4().to_string()).collect();
12909        let app = Router::new()
12910            .route("/api/v1/consolidate", axum_post(consolidate_memories))
12911            .with_state(test_app_state(state));
12912        let body = serde_json::json!({
12913            "ids": ids,
12914            "title": "too-many",
12915            "summary": "x",
12916            "namespace": "merge-ns"
12917        });
12918        let resp = app
12919            .oneshot(
12920                axum::http::Request::builder()
12921                    .uri("/api/v1/consolidate")
12922                    .method("POST")
12923                    .header("content-type", "application/json")
12924                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12925                    .unwrap(),
12926            )
12927            .await
12928            .unwrap();
12929        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12930    }
12931
12932    #[tokio::test]
12933    async fn http_consolidate_missing_source_500() {
12934        // Two well-formed UUIDs but the rows don't exist — db::consolidate
12935        // bails inside the transaction, surface as 500. This covers the
12936        // post-validation error arm of the handler.
12937        let state = test_state();
12938        let id_a = Uuid::new_v4().to_string();
12939        let id_b = Uuid::new_v4().to_string();
12940        let app = Router::new()
12941            .route("/api/v1/consolidate", axum_post(consolidate_memories))
12942            .with_state(test_app_state(state));
12943        let body = serde_json::json!({
12944            "ids": [id_a, id_b],
12945            "title": "merge",
12946            "summary": "x",
12947            "namespace": "merge-ns"
12948        });
12949        let resp = app
12950            .oneshot(
12951                axum::http::Request::builder()
12952                    .uri("/api/v1/consolidate")
12953                    .method("POST")
12954                    .header("content-type", "application/json")
12955                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
12956                    .unwrap(),
12957            )
12958            .await
12959            .unwrap();
12960        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
12961    }
12962
12963    // ---- detect_contradictions (GET /api/v1/contradictions) ----------------
12964
12965    #[tokio::test]
12966    async fn http_contradictions_empty_no_pairs() {
12967        // namespace exists in the URL but no memories → empty memories,
12968        // empty links. Still a 200.
12969        let state = test_state();
12970        let app = Router::new()
12971            .route("/api/v1/contradictions", axum_get(detect_contradictions))
12972            .with_state(state);
12973        let resp = app
12974            .oneshot(
12975                axum::http::Request::builder()
12976                    .uri("/api/v1/contradictions?namespace=empty-ns")
12977                    .body(Body::empty())
12978                    .unwrap(),
12979            )
12980            .await
12981            .unwrap();
12982        assert_eq!(resp.status(), StatusCode::OK);
12983        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12984            .await
12985            .unwrap();
12986        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12987        assert_eq!(v["memories"].as_array().unwrap().len(), 0);
12988        assert_eq!(v["links"].as_array().unwrap().len(), 0);
12989    }
12990
12991    #[tokio::test]
12992    async fn http_contradictions_synthesizes_links_for_same_title() {
12993        // Two memories with the same TITLE but different content in a
12994        // namespace produce a synthesized contradicts link.
12995        let state = test_state();
12996        let now = Utc::now().to_rfc3339();
12997        {
12998            let lock = state.lock().await;
12999            // Same title forces UPSERT collapse, so vary metadata.topic for grouping.
13000            let mk = |title: &str, content: &str| Memory {
13001                id: Uuid::new_v4().to_string(),
13002                tier: Tier::Long,
13003                namespace: "contradict-ns".into(),
13004                title: title.into(),
13005                content: content.into(),
13006                tags: vec![],
13007                priority: 5,
13008                confidence: 1.0,
13009                source: "api".into(),
13010                access_count: 0,
13011                created_at: now.clone(),
13012                updated_at: now.clone(),
13013                last_accessed_at: None,
13014                expires_at: None,
13015                metadata: serde_json::json!({"topic": "earth-shape"}),
13016            };
13017            db::insert(&lock.0, &mk("alice-says", "earth is round")).unwrap();
13018            db::insert(&lock.0, &mk("bob-says", "earth is flat")).unwrap();
13019        }
13020        let app = Router::new()
13021            .route("/api/v1/contradictions", axum_get(detect_contradictions))
13022            .with_state(state);
13023        let resp = app
13024            .oneshot(
13025                axum::http::Request::builder()
13026                    .uri("/api/v1/contradictions?namespace=contradict-ns&topic=earth-shape")
13027                    .body(Body::empty())
13028                    .unwrap(),
13029            )
13030            .await
13031            .unwrap();
13032        assert_eq!(resp.status(), StatusCode::OK);
13033        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13034            .await
13035            .unwrap();
13036        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13037        let memories = v["memories"].as_array().unwrap();
13038        assert_eq!(memories.len(), 2);
13039        let links = v["links"].as_array().unwrap();
13040        assert!(links.iter().any(|l| {
13041            l["relation"].as_str() == Some("contradicts")
13042                && l["synthesized"].as_bool() == Some(true)
13043        }));
13044    }
13045
13046    #[tokio::test]
13047    async fn http_contradictions_namespace_filter_isolates_results() {
13048        // Memories in ns-A vs ns-B — querying ns-A only returns its rows
13049        // even though ns-B has a same-titled candidate.
13050        let state = test_state();
13051        let now = Utc::now().to_rfc3339();
13052        {
13053            let lock = state.lock().await;
13054            let mk = |ns: &str, content: &str| Memory {
13055                id: Uuid::new_v4().to_string(),
13056                tier: Tier::Long,
13057                namespace: ns.into(),
13058                title: "shared-topic".into(),
13059                content: content.into(),
13060                tags: vec![],
13061                priority: 5,
13062                confidence: 1.0,
13063                source: "api".into(),
13064                access_count: 0,
13065                created_at: now.clone(),
13066                updated_at: now.clone(),
13067                last_accessed_at: None,
13068                expires_at: None,
13069                metadata: serde_json::json!({}),
13070            };
13071            db::insert(&lock.0, &mk("ns-iso-a", "first opinion")).unwrap();
13072            db::insert(&lock.0, &mk("ns-iso-b", "different opinion")).unwrap();
13073        }
13074        let app = Router::new()
13075            .route("/api/v1/contradictions", axum_get(detect_contradictions))
13076            .with_state(state);
13077        let resp = app
13078            .oneshot(
13079                axum::http::Request::builder()
13080                    .uri("/api/v1/contradictions?namespace=ns-iso-a")
13081                    .body(Body::empty())
13082                    .unwrap(),
13083            )
13084            .await
13085            .unwrap();
13086        assert_eq!(resp.status(), StatusCode::OK);
13087        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13088            .await
13089            .unwrap();
13090        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13091        let memories = v["memories"].as_array().unwrap();
13092        assert_eq!(memories.len(), 1, "ns filter must isolate results");
13093        assert_eq!(memories[0]["namespace"], "ns-iso-a");
13094    }
13095
13096    #[tokio::test]
13097    async fn http_contradictions_invalid_namespace_400() {
13098        // A namespace string with a space fails validate_namespace
13099        // before any DB read.
13100        let state = test_state();
13101        let app = Router::new()
13102            .route("/api/v1/contradictions", axum_get(detect_contradictions))
13103            .with_state(state);
13104        let resp = app
13105            .oneshot(
13106                axum::http::Request::builder()
13107                    .uri("/api/v1/contradictions?namespace=bad%20ns")
13108                    .body(Body::empty())
13109                    .unwrap(),
13110            )
13111            .await
13112            .unwrap();
13113        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13114    }
13115
13116    // ---- get_capabilities (GET /api/v1/capabilities) -----------------------
13117
13118    #[tokio::test]
13119    async fn http_capabilities_returns_expected_shape() {
13120        // Confirm the response includes tier/version/features/models —
13121        // the four top-level keys our scenarios depend on.
13122        let state = test_state();
13123        let app = Router::new()
13124            .route("/api/v1/capabilities", axum_get(get_capabilities))
13125            .with_state(test_app_state(state));
13126        let resp = app
13127            .oneshot(
13128                axum::http::Request::builder()
13129                    .uri("/api/v1/capabilities")
13130                    .body(Body::empty())
13131                    .unwrap(),
13132            )
13133            .await
13134            .unwrap();
13135        assert_eq!(resp.status(), StatusCode::OK);
13136        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13137            .await
13138            .unwrap();
13139        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13140        assert!(v.get("tier").is_some(), "missing `tier`");
13141        assert!(v.get("version").is_some(), "missing `version`");
13142        assert!(v.get("features").is_some(), "missing `features`");
13143        assert!(v.get("models").is_some(), "missing `models`");
13144        // The Keyword tier defaults: keyword_search=true, no LLM features.
13145        assert_eq!(v["features"]["keyword_search"], true);
13146        assert_eq!(v["features"]["semantic_search"], false);
13147        assert_eq!(v["features"]["query_expansion"], false);
13148    }
13149
13150    /// v0.6.3.1 (capabilities schema v2 — P1 honesty patch).
13151    /// HTTP surface mirrors the MCP shape: every new top-level block is
13152    /// present, `schema_version="2"`, and dropped fields are absent.
13153    #[tokio::test]
13154    async fn http_capabilities_v2_schema_includes_all_blocks() {
13155        let state = test_state();
13156        let app = Router::new()
13157            .route("/api/v1/capabilities", axum_get(get_capabilities))
13158            .with_state(test_app_state(state));
13159        let resp = app
13160            .oneshot(
13161                axum::http::Request::builder()
13162                    .uri("/api/v1/capabilities")
13163                    .body(Body::empty())
13164                    .unwrap(),
13165            )
13166            .await
13167            .unwrap();
13168        assert_eq!(resp.status(), StatusCode::OK);
13169        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13170            .await
13171            .unwrap();
13172        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13173
13174        assert_eq!(v["schema_version"], "2");
13175
13176        // permissions: mode=advisory (P1), active_rules live, no rule_summary
13177        assert!(v["permissions"].is_object());
13178        assert_eq!(v["permissions"]["mode"], "advisory");
13179        assert!(v["permissions"]["active_rules"].is_number());
13180        assert!(v["permissions"].get("rule_summary").is_none());
13181        // v0.6.3.1 (P4, audit G1): inheritance posture surfaced.
13182        assert_eq!(v["permissions"]["inheritance"], "enforced");
13183
13184        // hooks: registered_count live, no by_event
13185        assert!(v["hooks"].is_object());
13186        assert!(v["hooks"]["registered_count"].is_number());
13187        assert!(v["hooks"].get("by_event").is_none());
13188
13189        // compaction: planned-feature shape
13190        assert!(v["compaction"].is_object());
13191        assert_eq!(v["compaction"]["planned"], true);
13192        assert_eq!(v["compaction"]["enabled"], false);
13193        assert_eq!(v["compaction"]["version"], "v0.8+");
13194
13195        // approval: pending_requests live, no subscribers/timeout
13196        assert!(v["approval"].is_object());
13197        assert!(v["approval"]["pending_requests"].is_number());
13198        assert!(v["approval"].get("subscribers").is_none());
13199        assert!(v["approval"].get("default_timeout_seconds").is_none());
13200
13201        // transcripts: planned-feature shape
13202        assert!(v["transcripts"].is_object());
13203        assert_eq!(v["transcripts"]["planned"], true);
13204        assert_eq!(v["transcripts"]["enabled"], false);
13205
13206        // P1: live recall/reranker mode tags present (default tier
13207        // here is keyword with no embedder → disabled / off).
13208        assert_eq!(v["features"]["recall_mode_active"], "disabled");
13209        assert_eq!(v["features"]["reranker_active"], "off");
13210        // memory_reflection reshaped to a planned object
13211        assert_eq!(v["features"]["memory_reflection"]["planned"], true);
13212    }
13213
13214    #[tokio::test]
13215    async fn http_capabilities_version_matches_pkg_version() {
13216        // version must equal CARGO_PKG_VERSION — operators pin scenarios
13217        // by this string, regressions here break upgrade tooling.
13218        let state = test_state();
13219        let app = Router::new()
13220            .route("/api/v1/capabilities", axum_get(get_capabilities))
13221            .with_state(test_app_state(state));
13222        let resp = app
13223            .oneshot(
13224                axum::http::Request::builder()
13225                    .uri("/api/v1/capabilities")
13226                    .body(Body::empty())
13227                    .unwrap(),
13228            )
13229            .await
13230            .unwrap();
13231        assert_eq!(resp.status(), StatusCode::OK);
13232        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13233            .await
13234            .unwrap();
13235        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13236        assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
13237        assert_eq!(v["tier"], "keyword");
13238    }
13239    // ====================================================================
13240    // W8/H8d — dual-form `*_qs` namespace handlers + `fanout_or_503` matrix
13241    // --------------------------------------------------------------------
13242    // `set/get/clear_namespace_standard_qs` are the query-string twins of
13243    // the path-form handlers used by S34/S35 (`/api/v1/namespaces?namespace=…`).
13244    // The QS-form arms were uncovered prior to this batch — both the
13245    // happy paths and the 400-on-missing-namespace branches needed direct
13246    // exercise. The `fanout_or_503` 503 paths are exercised through the
13247    // QS-form `set` handler (`set_namespace_standard_inner` calls
13248    // `fanout_or_503` for the standard memory and then
13249    // `broadcast_namespace_meta_quorum` for the meta row); the same
13250    // mock-peer helper used by the W3 federation tests drives both.
13251    // ====================================================================
13252
13253    // --- helpers shared across the W8/H8d tests --------------------------
13254
13255    /// Spawn a mock peer that records every `POST /api/v1/sync/push` and
13256    /// responds according to `behaviour`. Returns the base URL and the
13257    /// shared call-counter so tests can both target the peer and assert
13258    /// how many fanout POSTs reached it.
13259    async fn h8d_spawn_mock_peer(
13260        behaviour: H8dPeerBehaviour,
13261    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
13262        use std::sync::atomic::{AtomicUsize, Ordering};
13263        use tokio::net::TcpListener;
13264
13265        let count = Arc::new(AtomicUsize::new(0));
13266        let count_for_peer = count.clone();
13267        #[derive(Clone)]
13268        struct PeerState {
13269            count: Arc<AtomicUsize>,
13270            behaviour: H8dPeerBehaviour,
13271        }
13272        async fn handler(
13273            axum::extract::State(s): axum::extract::State<PeerState>,
13274            Json(_body): Json<serde_json::Value>,
13275        ) -> (StatusCode, Json<serde_json::Value>) {
13276            s.count.fetch_add(1, Ordering::Relaxed);
13277            match s.behaviour {
13278                H8dPeerBehaviour::Ack => (
13279                    StatusCode::OK,
13280                    Json(json!({"applied": 1, "noop": 0, "skipped": 0})),
13281                ),
13282                H8dPeerBehaviour::Fail500 => (
13283                    StatusCode::INTERNAL_SERVER_ERROR,
13284                    Json(json!({"error": "stub failure"})),
13285                ),
13286                H8dPeerBehaviour::Fail503 => (
13287                    StatusCode::SERVICE_UNAVAILABLE,
13288                    Json(json!({"error": "stub unavailable"})),
13289                ),
13290                H8dPeerBehaviour::Fail400 => (
13291                    StatusCode::BAD_REQUEST,
13292                    Json(json!({"error": "stub bad request"})),
13293                ),
13294                H8dPeerBehaviour::Hang => {
13295                    tokio::time::sleep(std::time::Duration::from_secs(10)).await;
13296                    (StatusCode::OK, Json(json!({"applied": 1})))
13297                }
13298            }
13299        }
13300        let app = Router::new()
13301            .route("/api/v1/sync/push", axum_post(handler))
13302            .with_state(PeerState {
13303                count: count_for_peer,
13304                behaviour,
13305            });
13306        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
13307        let addr = listener.local_addr().unwrap();
13308        tokio::spawn(async move {
13309            axum::serve(listener, app).await.ok();
13310        });
13311        (format!("http://{addr}"), count)
13312    }
13313
13314    #[derive(Clone, Copy)]
13315    enum H8dPeerBehaviour {
13316        /// Always returns 200 OK with the standard ack envelope.
13317        Ack,
13318        /// Always returns 500 Internal Server Error.
13319        Fail500,
13320        /// Always returns 503 Service Unavailable.
13321        Fail503,
13322        /// Always returns 400 Bad Request.
13323        Fail400,
13324        /// Sleeps 10s before responding — exercises timeout / unreachable
13325        /// classification when `--quorum-timeout-ms` is shorter.
13326        Hang,
13327    }
13328
13329    /// Build an `AppState` wired to a `FederationConfig` that points at
13330    /// `peer_urls` with quorum width `w` and the given timeout. Mirrors
13331    /// the construction used by `http_bulk_create_fans_out_with_federation`.
13332    fn h8d_app_state_with_fed(
13333        db: Db,
13334        peer_urls: Vec<String>,
13335        w: usize,
13336        timeout_ms: u64,
13337    ) -> AppState {
13338        let fed = crate::federation::FederationConfig::build(
13339            w,
13340            &peer_urls,
13341            std::time::Duration::from_millis(timeout_ms),
13342            None,
13343            None,
13344            None,
13345            "ai:h8d-test".to_string(),
13346        )
13347        .unwrap()
13348        .expect("federation must be built");
13349        AppState {
13350            db,
13351            embedder: Arc::new(None),
13352            vector_index: Arc::new(Mutex::new(None)),
13353            federation: Arc::new(Some(fed)),
13354            tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
13355            scoring: Arc::new(crate::config::ResolvedScoring::default()),
13356        }
13357    }
13358
13359    // --- get_namespace_standard_qs --------------------------------------
13360
13361    #[tokio::test]
13362    async fn http_get_namespace_standard_qs_returns_standard_for_existing_ns() {
13363        // Pre-seed a namespace standard via the inner DB call so we can
13364        // assert the QS handler reads it back. We use the path-form set
13365        // handler with no federation so the write is local-only.
13366        let state = test_state();
13367        let app_state = test_app_state(state.clone());
13368        let set_router = Router::new()
13369            .route(
13370                "/api/v1/namespaces/{ns}/standard",
13371                axum_post(set_namespace_standard),
13372            )
13373            .with_state(app_state);
13374        let resp = set_router
13375            .oneshot(
13376                axum::http::Request::builder()
13377                    .uri("/api/v1/namespaces/qs-existing/standard")
13378                    .method("POST")
13379                    .header("content-type", "application/json")
13380                    .body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
13381                    .unwrap(),
13382            )
13383            .await
13384            .unwrap();
13385        assert_eq!(resp.status(), StatusCode::CREATED);
13386
13387        // Now fetch via the QS form. Should return 200 with the standard
13388        // payload (namespace + standard_id).
13389        let get_router = Router::new()
13390            .route(
13391                "/api/v1/namespaces",
13392                axum::routing::get(get_namespace_standard_qs),
13393            )
13394            .with_state(state);
13395        let resp = get_router
13396            .oneshot(
13397                axum::http::Request::builder()
13398                    .uri("/api/v1/namespaces?namespace=qs-existing")
13399                    .body(Body::empty())
13400                    .unwrap(),
13401            )
13402            .await
13403            .unwrap();
13404        assert_eq!(resp.status(), StatusCode::OK);
13405        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13406            .await
13407            .unwrap();
13408        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13409        assert_eq!(v["namespace"], "qs-existing");
13410        assert!(v["standard_id"].is_string(), "standard_id must be set");
13411    }
13412
13413    #[tokio::test]
13414    async fn http_get_namespace_standard_qs_returns_null_for_missing_ns_record() {
13415        // A namespace that has never had a standard set returns the same
13416        // `{namespace, standard_id: null}` envelope the path-form does —
13417        // the MCP handler differentiates by `standard_id == null`.
13418        let state = test_state();
13419        let app = Router::new()
13420            .route(
13421                "/api/v1/namespaces",
13422                axum::routing::get(get_namespace_standard_qs),
13423            )
13424            .with_state(state);
13425        let resp = app
13426            .oneshot(
13427                axum::http::Request::builder()
13428                    .uri("/api/v1/namespaces?namespace=qs-never-set")
13429                    .body(Body::empty())
13430                    .unwrap(),
13431            )
13432            .await
13433            .unwrap();
13434        assert_eq!(resp.status(), StatusCode::OK);
13435        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13436            .await
13437            .unwrap();
13438        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13439        assert_eq!(v["namespace"], "qs-never-set");
13440        assert!(
13441            v["standard_id"].is_null(),
13442            "standard_id must be null for an unset namespace"
13443        );
13444    }
13445
13446    #[tokio::test]
13447    async fn http_get_namespace_standard_qs_falls_through_to_list_on_missing_param() {
13448        // The QS-form GET deliberately reuses the bare /api/v1/namespaces
13449        // route — when `?namespace=` is absent it must delegate to
13450        // `list_namespaces`, NOT 400. This pins the chained-route contract
13451        // documented inline at the handler.
13452        let state = test_state();
13453        let app = Router::new()
13454            .route(
13455                "/api/v1/namespaces",
13456                axum::routing::get(get_namespace_standard_qs),
13457            )
13458            .with_state(state);
13459        let resp = app
13460            .oneshot(
13461                axum::http::Request::builder()
13462                    .uri("/api/v1/namespaces")
13463                    .body(Body::empty())
13464                    .unwrap(),
13465            )
13466            .await
13467            .unwrap();
13468        assert_eq!(resp.status(), StatusCode::OK);
13469        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13470            .await
13471            .unwrap();
13472        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13473        assert!(
13474            v["namespaces"].is_array(),
13475            "fallthrough must produce the list shape, got {v:?}"
13476        );
13477    }
13478
13479    #[tokio::test]
13480    async fn http_get_namespace_standard_qs_inherit_flag_returns_chain() {
13481        // Cover the `?inherit=true` arm, which routes through the
13482        // `chain` / `standards` branch of `handle_namespace_get_standard`.
13483        let state = test_state();
13484        let app = Router::new()
13485            .route(
13486                "/api/v1/namespaces",
13487                axum::routing::get(get_namespace_standard_qs),
13488            )
13489            .with_state(state);
13490        let resp = app
13491            .oneshot(
13492                axum::http::Request::builder()
13493                    .uri("/api/v1/namespaces?namespace=child&inherit=true")
13494                    .body(Body::empty())
13495                    .unwrap(),
13496            )
13497            .await
13498            .unwrap();
13499        assert_eq!(resp.status(), StatusCode::OK);
13500        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13501            .await
13502            .unwrap();
13503        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13504        assert!(v["chain"].is_array(), "inherit must surface the chain");
13505        assert!(v["standards"].is_array());
13506    }
13507
13508    #[tokio::test]
13509    async fn http_get_namespace_standard_qs_invalid_namespace_returns_400() {
13510        // Ultrareview #337 — URL-decoded namespace flows through
13511        // `validate_namespace`. A namespace with disallowed bytes must
13512        // surface as 400 from the handler, not 500.
13513        let state = test_state();
13514        let app = Router::new()
13515            .route(
13516                "/api/v1/namespaces",
13517                axum::routing::get(get_namespace_standard_qs),
13518            )
13519            .with_state(state);
13520        // Spaces decode out of `%20` and fail `validate_namespace`.
13521        let resp = app
13522            .oneshot(
13523                axum::http::Request::builder()
13524                    .uri("/api/v1/namespaces?namespace=bad%20ns")
13525                    .body(Body::empty())
13526                    .unwrap(),
13527            )
13528            .await
13529            .unwrap();
13530        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13531    }
13532
13533    // --- set_namespace_standard_qs --------------------------------------
13534
13535    #[tokio::test]
13536    async fn http_set_namespace_standard_qs_happy_path_creates_placeholder() {
13537        // Body carries `namespace` (S34 shape, no URL segment). With no
13538        // federation configured the inner fn auto-seeds a placeholder
13539        // standard memory and returns 201 CREATED.
13540        let state = test_state();
13541        let app = Router::new()
13542            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13543            .with_state(test_app_state(state.clone()));
13544        let body = json!({"namespace": "qs-set-happy"});
13545        let resp = app
13546            .oneshot(
13547                axum::http::Request::builder()
13548                    .uri("/api/v1/namespaces")
13549                    .method("POST")
13550                    .header("content-type", "application/json")
13551                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
13552                    .unwrap(),
13553            )
13554            .await
13555            .unwrap();
13556        assert_eq!(resp.status(), StatusCode::CREATED);
13557        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13558            .await
13559            .unwrap();
13560        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13561        assert_eq!(v["namespace"], "qs-set-happy");
13562        assert_eq!(v["set"], true);
13563        assert!(v["standard_id"].is_string());
13564    }
13565
13566    #[tokio::test]
13567    async fn http_set_namespace_standard_qs_missing_namespace_returns_400() {
13568        // No `namespace` in body and no nested `standard.namespace` —
13569        // the QS-form set handler bails with 400 before touching the DB.
13570        let state = test_state();
13571        let app = Router::new()
13572            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13573            .with_state(test_app_state(state));
13574        let body = json!({"governance": {"approver": "human"}});
13575        let resp = app
13576            .oneshot(
13577                axum::http::Request::builder()
13578                    .uri("/api/v1/namespaces")
13579                    .method("POST")
13580                    .header("content-type", "application/json")
13581                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
13582                    .unwrap(),
13583            )
13584            .await
13585            .unwrap();
13586        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13587        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13588            .await
13589            .unwrap();
13590        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13591        assert!(
13592            v["error"].as_str().unwrap_or("").contains("namespace"),
13593            "error must mention the missing namespace, got {v:?}"
13594        );
13595    }
13596
13597    #[tokio::test]
13598    async fn http_set_namespace_standard_qs_invalid_governance_returns_400() {
13599        // Pre-seed a real memory we can target by id, so we get past the
13600        // placeholder branch and into `validate_governance_policy`.
13601        let state = test_state();
13602        let mem_id = {
13603            let lock = state.lock().await;
13604            let now = Utc::now().to_rfc3339();
13605            let mem = Memory {
13606                id: Uuid::new_v4().to_string(),
13607                tier: Tier::Long,
13608                namespace: "qs-set-bad-policy".into(),
13609                title: "anchor".into(),
13610                content: "anchor".into(),
13611                tags: vec![],
13612                priority: 5,
13613                confidence: 1.0,
13614                source: "test".into(),
13615                access_count: 0,
13616                created_at: now.clone(),
13617                updated_at: now,
13618                last_accessed_at: None,
13619                expires_at: None,
13620                metadata: serde_json::json!({}),
13621            };
13622            db::insert(&lock.0, &mem).unwrap()
13623        };
13624        let app = Router::new()
13625            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13626            .with_state(test_app_state(state));
13627        // `consensus: 0` is always invalid (validator rejects it).
13628        let body = json!({
13629            "namespace": "qs-set-bad-policy",
13630            "id": mem_id,
13631            "governance": {
13632                "approver": {"consensus": 0},
13633                "write": "approve",
13634                "promote": "log",
13635                "delete": "log"
13636            }
13637        });
13638        let resp = app
13639            .oneshot(
13640                axum::http::Request::builder()
13641                    .uri("/api/v1/namespaces")
13642                    .method("POST")
13643                    .header("content-type", "application/json")
13644                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
13645                    .unwrap(),
13646            )
13647            .await
13648            .unwrap();
13649        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13650    }
13651
13652    #[tokio::test]
13653    async fn http_set_namespace_standard_qs_nested_standard_payload_works() {
13654        // S34's body shape nests fields under `standard: { … }`. The
13655        // QS-form set handler must read either `body.namespace` or
13656        // `body.standard.namespace`. This exercises the second arm.
13657        let state = test_state();
13658        let app = Router::new()
13659            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13660            .with_state(test_app_state(state));
13661        let body = json!({"standard": {"namespace": "qs-nested-ns"}});
13662        let resp = app
13663            .oneshot(
13664                axum::http::Request::builder()
13665                    .uri("/api/v1/namespaces")
13666                    .method("POST")
13667                    .header("content-type", "application/json")
13668                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
13669                    .unwrap(),
13670            )
13671            .await
13672            .unwrap();
13673        assert_eq!(resp.status(), StatusCode::CREATED);
13674        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13675            .await
13676            .unwrap();
13677        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13678        assert_eq!(v["namespace"], "qs-nested-ns");
13679    }
13680
13681    // --- clear_namespace_standard_qs ------------------------------------
13682
13683    #[tokio::test]
13684    async fn http_clear_namespace_standard_qs_happy_path_after_set() {
13685        // Set then clear. Clear must return 200 with `{cleared: true|…}`.
13686        let state = test_state();
13687        let app_state = test_app_state(state.clone());
13688        let set_router = Router::new()
13689            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13690            .with_state(app_state.clone());
13691        let _ = set_router
13692            .oneshot(
13693                axum::http::Request::builder()
13694                    .uri("/api/v1/namespaces")
13695                    .method("POST")
13696                    .header("content-type", "application/json")
13697                    .body(Body::from(
13698                        serde_json::to_vec(&json!({"namespace": "qs-clear-happy"})).unwrap(),
13699                    ))
13700                    .unwrap(),
13701            )
13702            .await
13703            .unwrap();
13704
13705        let clear_router = Router::new()
13706            .route(
13707                "/api/v1/namespaces",
13708                axum::routing::delete(clear_namespace_standard_qs),
13709            )
13710            .with_state(app_state);
13711        let resp = clear_router
13712            .oneshot(
13713                axum::http::Request::builder()
13714                    .uri("/api/v1/namespaces?namespace=qs-clear-happy")
13715                    .method("DELETE")
13716                    .body(Body::empty())
13717                    .unwrap(),
13718            )
13719            .await
13720            .unwrap();
13721        assert_eq!(resp.status(), StatusCode::OK);
13722        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13723            .await
13724            .unwrap();
13725        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13726        assert_eq!(v["namespace"], "qs-clear-happy");
13727    }
13728
13729    #[tokio::test]
13730    async fn http_clear_namespace_standard_qs_idempotent_on_unset() {
13731        // Clearing a namespace that has no standard set is a no-op
13732        // success (idempotency). The MCP handler returns
13733        // `{cleared: <bool>, namespace}` rather than 404.
13734        let state = test_state();
13735        let app = Router::new()
13736            .route(
13737                "/api/v1/namespaces",
13738                axum::routing::delete(clear_namespace_standard_qs),
13739            )
13740            .with_state(test_app_state(state));
13741        let resp = app
13742            .oneshot(
13743                axum::http::Request::builder()
13744                    .uri("/api/v1/namespaces?namespace=qs-clear-noop")
13745                    .method("DELETE")
13746                    .body(Body::empty())
13747                    .unwrap(),
13748            )
13749            .await
13750            .unwrap();
13751        assert_eq!(resp.status(), StatusCode::OK);
13752    }
13753
13754    #[tokio::test]
13755    async fn http_clear_namespace_standard_qs_missing_namespace_returns_400() {
13756        // No `?namespace=…` → 400 BadRequest with an `error` payload that
13757        // names the missing field.
13758        let state = test_state();
13759        let app = Router::new()
13760            .route(
13761                "/api/v1/namespaces",
13762                axum::routing::delete(clear_namespace_standard_qs),
13763            )
13764            .with_state(test_app_state(state));
13765        let resp = app
13766            .oneshot(
13767                axum::http::Request::builder()
13768                    .uri("/api/v1/namespaces")
13769                    .method("DELETE")
13770                    .body(Body::empty())
13771                    .unwrap(),
13772            )
13773            .await
13774            .unwrap();
13775        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13776        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13777            .await
13778            .unwrap();
13779        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13780        assert!(
13781            v["error"].as_str().unwrap_or("").contains("namespace"),
13782            "error must mention namespace, got {v:?}"
13783        );
13784    }
13785
13786    // --- fanout_or_503 / quorum_not_met error matrix --------------------
13787
13788    #[tokio::test]
13789    async fn http_set_qs_fanout_503_when_all_peers_down() {
13790        // Single peer, W=2 (local + 1 peer required). Peer 500s on every
13791        // POST → cannot meet quorum → 503 `quorum_not_met` payload.
13792        let state = test_state();
13793        let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13794        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13795        let app = Router::new()
13796            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13797            .with_state(app_state);
13798        let resp = app
13799            .oneshot(
13800                axum::http::Request::builder()
13801                    .uri("/api/v1/namespaces")
13802                    .method("POST")
13803                    .header("content-type", "application/json")
13804                    .body(Body::from(
13805                        serde_json::to_vec(&json!({"namespace": "qs-fed-down"})).unwrap(),
13806                    ))
13807                    .unwrap(),
13808            )
13809            .await
13810            .unwrap();
13811        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13812    }
13813
13814    #[tokio::test]
13815    async fn http_set_qs_fanout_503_payload_shape_includes_quorum_fields() {
13816        // The 503 body must round-trip through `QuorumNotMetPayload` and
13817        // surface `error="quorum_not_met"`, `got`, `needed`, `reason`.
13818        // Single peer down @ W=2 → got=1 (local), needed=2, reason names
13819        // the failure (unreachable / 500 → "unreachable").
13820        let state = test_state();
13821        let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13822        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13823        let app = Router::new()
13824            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13825            .with_state(app_state);
13826        let resp = app
13827            .oneshot(
13828                axum::http::Request::builder()
13829                    .uri("/api/v1/namespaces")
13830                    .method("POST")
13831                    .header("content-type", "application/json")
13832                    .body(Body::from(
13833                        serde_json::to_vec(&json!({"namespace": "qs-503-shape"})).unwrap(),
13834                    ))
13835                    .unwrap(),
13836            )
13837            .await
13838            .unwrap();
13839        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13840        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13841            .await
13842            .unwrap();
13843        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13844        assert_eq!(v["error"], "quorum_not_met");
13845        assert!(v["got"].as_u64().is_some(), "got must be a number");
13846        assert!(v["needed"].as_u64().is_some(), "needed must be a number");
13847        assert!(v["reason"].is_string(), "reason must be a string");
13848        // Local always commits → got >= 1; needed must equal W=2.
13849        assert_eq!(v["needed"].as_u64().unwrap(), 2);
13850    }
13851
13852    #[tokio::test]
13853    async fn http_set_qs_fanout_503_includes_retry_after_header() {
13854        // The 503 path returns a `Retry-After: 2` header so clients can
13855        // back off without parsing the body.
13856        let state = test_state();
13857        let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13858        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13859        let app = Router::new()
13860            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13861            .with_state(app_state);
13862        let resp = app
13863            .oneshot(
13864                axum::http::Request::builder()
13865                    .uri("/api/v1/namespaces")
13866                    .method("POST")
13867                    .header("content-type", "application/json")
13868                    .body(Body::from(
13869                        serde_json::to_vec(&json!({"namespace": "qs-503-retry-after"})).unwrap(),
13870                    ))
13871                    .unwrap(),
13872            )
13873            .await
13874            .unwrap();
13875        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13876        let retry = resp
13877            .headers()
13878            .get("retry-after")
13879            .and_then(|v| v.to_str().ok())
13880            .unwrap_or("");
13881        assert_eq!(retry, "2", "503 must include Retry-After: 2");
13882    }
13883
13884    #[tokio::test]
13885    async fn http_set_qs_fanout_quorum_met_with_one_peer_down() {
13886        // N=3, W=2 (majority). One peer 500s, one peer acks → quorum
13887        // met → 201 CREATED. Exercises the quorum-not-all-fail success
13888        // branch of `fanout_or_503` (`Ok(_) => None`).
13889        let state = test_state();
13890        let (peer_up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
13891        let (peer_down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13892        let app_state = h8d_app_state_with_fed(state, vec![peer_up, peer_down], 2, 1500);
13893        let app = Router::new()
13894            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13895            .with_state(app_state);
13896        let resp = app
13897            .oneshot(
13898                axum::http::Request::builder()
13899                    .uri("/api/v1/namespaces")
13900                    .method("POST")
13901                    .header("content-type", "application/json")
13902                    .body(Body::from(
13903                        serde_json::to_vec(&json!({"namespace": "qs-quorum-met"})).unwrap(),
13904                    ))
13905                    .unwrap(),
13906            )
13907            .await
13908            .unwrap();
13909        assert_eq!(resp.status(), StatusCode::CREATED);
13910    }
13911
13912    #[tokio::test]
13913    async fn http_set_qs_fanout_quorum_not_met_strict_n_equals_w() {
13914        // N=2, W=2 (all-or-nothing). Single peer down → 1/2 acks → 503.
13915        // This is the "strict" all-acks-required posture (W=N).
13916        let state = test_state();
13917        let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13918        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13919        let app = Router::new()
13920            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13921            .with_state(app_state);
13922        let resp = app
13923            .oneshot(
13924                axum::http::Request::builder()
13925                    .uri("/api/v1/namespaces")
13926                    .method("POST")
13927                    .header("content-type", "application/json")
13928                    .body(Body::from(
13929                        serde_json::to_vec(&json!({"namespace": "qs-strict-quorum"})).unwrap(),
13930                    ))
13931                    .unwrap(),
13932            )
13933            .await
13934            .unwrap();
13935        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13936        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13937            .await
13938            .unwrap();
13939        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13940        assert_eq!(v["needed"].as_u64().unwrap(), 2);
13941        // got must be < needed in the failure case.
13942        assert!(v["got"].as_u64().unwrap() < v["needed"].as_u64().unwrap());
13943    }
13944
13945    #[tokio::test]
13946    async fn http_set_qs_fanout_quorum_w_equals_one_any_success_writes_succeed() {
13947        // W=1 → local commit alone is enough; peer down doesn't 503.
13948        // This exercises the `K=1` (any-success) row in the matrix.
13949        let state = test_state();
13950        let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13951        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 1, 1500);
13952        let app = Router::new()
13953            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13954            .with_state(app_state);
13955        let resp = app
13956            .oneshot(
13957                axum::http::Request::builder()
13958                    .uri("/api/v1/namespaces")
13959                    .method("POST")
13960                    .header("content-type", "application/json")
13961                    .body(Body::from(
13962                        serde_json::to_vec(&json!({"namespace": "qs-w1-any"})).unwrap(),
13963                    ))
13964                    .unwrap(),
13965            )
13966            .await
13967            .unwrap();
13968        assert_eq!(resp.status(), StatusCode::CREATED);
13969    }
13970
13971    #[tokio::test]
13972    async fn http_set_qs_fanout_503_when_peer_hangs_past_deadline() {
13973        // Hanging peer + tight deadline → quorum_not_met with reason
13974        // "timeout" or "unreachable" (depending on whether the request
13975        // returned an error before the deadline). Either way → 503.
13976        let state = test_state();
13977        let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Hang).await;
13978        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 200);
13979        let app = Router::new()
13980            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13981            .with_state(app_state);
13982        let resp = app
13983            .oneshot(
13984                axum::http::Request::builder()
13985                    .uri("/api/v1/namespaces")
13986                    .method("POST")
13987                    .header("content-type", "application/json")
13988                    .body(Body::from(
13989                        serde_json::to_vec(&json!({"namespace": "qs-hang"})).unwrap(),
13990                    ))
13991                    .unwrap(),
13992            )
13993            .await
13994            .unwrap();
13995        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13996        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13997            .await
13998            .unwrap();
13999        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14000        let reason = v["reason"].as_str().unwrap_or("");
14001        assert!(
14002            reason == "timeout" || reason == "unreachable",
14003            "expected timeout/unreachable, got {reason:?}"
14004        );
14005    }
14006
14007    #[tokio::test]
14008    async fn http_set_qs_fanout_503_when_peer_returns_503() {
14009        // A peer that itself replies 503 (overloaded) is still a
14010        // failed ack. The leader's 503 response carries the federation
14011        // payload, not the peer's. (Smoke-tests that 5xx-class peers
14012        // beyond just 500 also count as failures.)
14013        let state = test_state();
14014        let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail503).await;
14015        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14016        let app = Router::new()
14017            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14018            .with_state(app_state);
14019        let resp = app
14020            .oneshot(
14021                axum::http::Request::builder()
14022                    .uri("/api/v1/namespaces")
14023                    .method("POST")
14024                    .header("content-type", "application/json")
14025                    .body(Body::from(
14026                        serde_json::to_vec(&json!({"namespace": "qs-peer-503"})).unwrap(),
14027                    ))
14028                    .unwrap(),
14029            )
14030            .await
14031            .unwrap();
14032        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14033        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14034            .await
14035            .unwrap();
14036        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14037        assert_eq!(v["error"], "quorum_not_met");
14038    }
14039
14040    #[tokio::test]
14041    async fn http_set_qs_fanout_503_when_peer_returns_4xx() {
14042        // 4xx from a peer also counts as a failed ack — the federation
14043        // ack tracker requires a 200 to count toward quorum. (Closes the
14044        // "200 + 4xx from peers" matrix row.)
14045        let state = test_state();
14046        let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail400).await;
14047        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14048        let app = Router::new()
14049            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14050            .with_state(app_state);
14051        let resp = app
14052            .oneshot(
14053                axum::http::Request::builder()
14054                    .uri("/api/v1/namespaces")
14055                    .method("POST")
14056                    .header("content-type", "application/json")
14057                    .body(Body::from(
14058                        serde_json::to_vec(&json!({"namespace": "qs-peer-400"})).unwrap(),
14059                    ))
14060                    .unwrap(),
14061            )
14062            .await
14063            .unwrap();
14064        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14065    }
14066
14067    #[tokio::test]
14068    async fn http_set_qs_fanout_503_partition_minority_fails() {
14069        // N=4 (local + 3 peers), W=3 (majority). Two peers down, one
14070        // up → can't meet quorum (got = 2, needed = 3) → 503.
14071        let state = test_state();
14072        let (up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14073        let (down1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14074        let (down2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14075        let app_state = h8d_app_state_with_fed(state, vec![up, down1, down2], 3, 1500);
14076        let app = Router::new()
14077            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14078            .with_state(app_state);
14079        let resp = app
14080            .oneshot(
14081                axum::http::Request::builder()
14082                    .uri("/api/v1/namespaces")
14083                    .method("POST")
14084                    .header("content-type", "application/json")
14085                    .body(Body::from(
14086                        serde_json::to_vec(&json!({"namespace": "qs-minority"})).unwrap(),
14087                    ))
14088                    .unwrap(),
14089            )
14090            .await
14091            .unwrap();
14092        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14093        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14094            .await
14095            .unwrap();
14096        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14097        assert_eq!(v["needed"].as_u64().unwrap(), 3);
14098        assert!(v["got"].as_u64().unwrap() < 3);
14099    }
14100
14101    #[tokio::test]
14102    async fn http_set_qs_fanout_majority_tolerates_minority_partition() {
14103        // N=4, W=3 (majority). Two peers up, one down → quorum met
14104        // (got = 3 ≥ needed = 3) → 201 CREATED. Mirror of the previous
14105        // test but with the failure flipped into a success.
14106        let state = test_state();
14107        let (up1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14108        let (up2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14109        let (down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14110        let app_state = h8d_app_state_with_fed(state, vec![up1, up2, down], 3, 1500);
14111        let app = Router::new()
14112            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14113            .with_state(app_state);
14114        let resp = app
14115            .oneshot(
14116                axum::http::Request::builder()
14117                    .uri("/api/v1/namespaces")
14118                    .method("POST")
14119                    .header("content-type", "application/json")
14120                    .body(Body::from(
14121                        serde_json::to_vec(&json!({"namespace": "qs-majority"})).unwrap(),
14122                    ))
14123                    .unwrap(),
14124            )
14125            .await
14126            .unwrap();
14127        assert_eq!(resp.status(), StatusCode::CREATED);
14128    }
14129
14130    #[tokio::test]
14131    async fn http_clear_qs_fanout_503_when_peer_down() {
14132        // The CLEAR path uses `broadcast_namespace_meta_clear_quorum`,
14133        // a different fanout function from `fanout_or_503`. Both share
14134        // the QuorumNotMetPayload contract and Retry-After=2 header.
14135        // This test exercises the clear-side 503 lane.
14136        let state = test_state();
14137        // Pre-seed a namespace standard so the clear has something to do.
14138        // We do this with no federation by using a separate AppState.
14139        let local_app_state = test_app_state(state.clone());
14140        let set_router = Router::new()
14141            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14142            .with_state(local_app_state);
14143        let _ = set_router
14144            .oneshot(
14145                axum::http::Request::builder()
14146                    .uri("/api/v1/namespaces")
14147                    .method("POST")
14148                    .header("content-type", "application/json")
14149                    .body(Body::from(
14150                        serde_json::to_vec(&json!({"namespace": "qs-clear-fed"})).unwrap(),
14151                    ))
14152                    .unwrap(),
14153            )
14154            .await
14155            .unwrap();
14156
14157        let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14158        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14159        let app = Router::new()
14160            .route(
14161                "/api/v1/namespaces",
14162                axum::routing::delete(clear_namespace_standard_qs),
14163            )
14164            .with_state(app_state);
14165        let resp = app
14166            .oneshot(
14167                axum::http::Request::builder()
14168                    .uri("/api/v1/namespaces?namespace=qs-clear-fed")
14169                    .method("DELETE")
14170                    .body(Body::empty())
14171                    .unwrap(),
14172            )
14173            .await
14174            .unwrap();
14175        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14176        let retry = resp
14177            .headers()
14178            .get("retry-after")
14179            .and_then(|v| v.to_str().ok())
14180            .unwrap_or("");
14181        assert_eq!(retry, "2", "clear 503 must include Retry-After: 2");
14182        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14183            .await
14184            .unwrap();
14185        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14186        assert_eq!(v["error"], "quorum_not_met");
14187    }
14188
14189    #[tokio::test]
14190    async fn http_set_qs_fanout_no_federation_returns_201_without_peers() {
14191        // No `--quorum-peers` configured → `app.federation` is None →
14192        // `fanout_or_503` short-circuits to None and the handler returns
14193        // 201 without any peer involvement. Pins the no-fed branch.
14194        let state = test_state();
14195        let app = Router::new()
14196            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14197            .with_state(test_app_state(state));
14198        let resp = app
14199            .oneshot(
14200                axum::http::Request::builder()
14201                    .uri("/api/v1/namespaces")
14202                    .method("POST")
14203                    .header("content-type", "application/json")
14204                    .body(Body::from(
14205                        serde_json::to_vec(&json!({"namespace": "qs-no-fed"})).unwrap(),
14206                    ))
14207                    .unwrap(),
14208            )
14209            .await
14210            .unwrap();
14211        assert_eq!(resp.status(), StatusCode::CREATED);
14212    }
14213
14214    #[tokio::test]
14215    async fn http_set_qs_fanout_peer_called_at_least_once_on_quorum_failure() {
14216        // Even when quorum fails, the leader must have *attempted* to
14217        // POST to the peer at least once. This guards against the
14218        // pre-flight short-circuit that would skip the fanout entirely.
14219        use std::sync::atomic::Ordering;
14220
14221        let state = test_state();
14222        let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14223        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14224        let app = Router::new()
14225            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14226            .with_state(app_state);
14227        let resp = app
14228            .oneshot(
14229                axum::http::Request::builder()
14230                    .uri("/api/v1/namespaces")
14231                    .method("POST")
14232                    .header("content-type", "application/json")
14233                    .body(Body::from(
14234                        serde_json::to_vec(&json!({"namespace": "qs-fanout-attempt"})).unwrap(),
14235                    ))
14236                    .unwrap(),
14237            )
14238            .await
14239            .unwrap();
14240        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14241        // Wait briefly for any retry to settle so the count is stable.
14242        for _ in 0..50 {
14243            if count.load(Ordering::Relaxed) >= 1 {
14244                break;
14245            }
14246            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
14247        }
14248        assert!(
14249            count.load(Ordering::Relaxed) >= 1,
14250            "leader must have attempted the fanout POST at least once"
14251        );
14252    }
14253
14254    #[tokio::test]
14255    async fn http_set_qs_fanout_peer_receives_post_on_happy_path() {
14256        // Counterpart to the failure-attempt test: on a happy path,
14257        // exactly one peer-side POST per fanout completes within a
14258        // short settle window.
14259        use std::sync::atomic::Ordering;
14260
14261        let state = test_state();
14262        let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14263        let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14264        let app = Router::new()
14265            .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14266            .with_state(app_state);
14267        let resp = app
14268            .oneshot(
14269                axum::http::Request::builder()
14270                    .uri("/api/v1/namespaces")
14271                    .method("POST")
14272                    .header("content-type", "application/json")
14273                    .body(Body::from(
14274                        serde_json::to_vec(&json!({"namespace": "qs-fanout-happy"})).unwrap(),
14275                    ))
14276                    .unwrap(),
14277            )
14278            .await
14279            .unwrap();
14280        assert_eq!(resp.status(), StatusCode::CREATED);
14281        // The set path triggers TWO fanout POSTs to each peer: one for
14282        // the standard memory (`fanout_or_503`) and one for the
14283        // namespace_meta row (`broadcast_namespace_meta_quorum`). Wait
14284        // for at least one to land — the second may be background-detached.
14285        for _ in 0..50 {
14286            if count.load(Ordering::Relaxed) >= 1 {
14287                break;
14288            }
14289            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
14290        }
14291        assert!(count.load(Ordering::Relaxed) >= 1);
14292    }
14293
14294    // -------------------------------------------------------------------
14295    // W12-B closer — handlers.rs long-tail sweep
14296    //
14297    // After W8 + W11, handlers.rs sits ~88-90%. The runs below target
14298    // small uncovered chunks scattered across the surface — internal
14299    // helpers (percent_decode_lossy, constant_time_eq), additional middleware
14300    // arms, and HTTP error/happy paths the existing fixture doesn't reach.
14301    // -------------------------------------------------------------------
14302
14303    // ---- percent_decode_lossy / constant_time_eq unit tests ----
14304
14305    #[test]
14306    fn percent_decode_lossy_passes_through_plain_ascii() {
14307        let s = percent_decode_lossy("hello-world_123");
14308        assert_eq!(s, "hello-world_123");
14309    }
14310
14311    #[test]
14312    fn percent_decode_lossy_decodes_basic_escape() {
14313        let s = percent_decode_lossy("a%20b");
14314        assert_eq!(s, "a b");
14315    }
14316
14317    #[test]
14318    fn percent_decode_lossy_decodes_plus_and_ampersand() {
14319        // %2B -> '+', %26 -> '&'
14320        let s = percent_decode_lossy("a%2Bb%26c");
14321        assert_eq!(s, "a+b&c");
14322    }
14323
14324    #[test]
14325    fn percent_decode_lossy_handles_invalid_hex_passthrough() {
14326        // %ZZ is not a valid hex escape — emit the bytes verbatim.
14327        let s = percent_decode_lossy("a%ZZb");
14328        assert_eq!(s, "a%ZZb");
14329    }
14330
14331    #[test]
14332    fn percent_decode_lossy_handles_truncated_escape() {
14333        // Trailing `%X` (only one hex char left) — passthrough.
14334        let s = percent_decode_lossy("a%2");
14335        assert_eq!(s, "a%2");
14336        let s2 = percent_decode_lossy("%");
14337        assert_eq!(s2, "%");
14338    }
14339
14340    #[test]
14341    fn percent_decode_lossy_decodes_full_byte_range() {
14342        // %FF -> 0xFF; resulting bytes round-trip through utf8_lossy.
14343        let s = percent_decode_lossy("%41%42%43");
14344        assert_eq!(s, "ABC");
14345    }
14346
14347    #[test]
14348    fn percent_decode_lossy_empty_input_returns_empty() {
14349        let s = percent_decode_lossy("");
14350        assert_eq!(s, "");
14351    }
14352
14353    #[test]
14354    fn constant_time_eq_returns_true_for_equal_bytes() {
14355        assert!(constant_time_eq(b"hello", b"hello"));
14356        assert!(constant_time_eq(b"", b""));
14357    }
14358
14359    #[test]
14360    fn constant_time_eq_returns_false_for_different_bytes() {
14361        assert!(!constant_time_eq(b"hello", b"world"));
14362    }
14363
14364    #[test]
14365    fn constant_time_eq_returns_false_for_different_lengths() {
14366        assert!(!constant_time_eq(b"a", b"ab"));
14367        assert!(!constant_time_eq(b"abc", b""));
14368    }
14369
14370    #[test]
14371    fn constant_time_eq_compares_high_bytes_correctly() {
14372        // 0x80..0xFF range — make sure XOR-or behavior matches.
14373        let a = [0x80u8, 0x81, 0x82, 0xFF];
14374        let b = [0x80u8, 0x81, 0x82, 0xFF];
14375        assert!(constant_time_eq(&a, &b));
14376        let c = [0x80u8, 0x81, 0x82, 0xFE];
14377        assert!(!constant_time_eq(&a, &c));
14378    }
14379
14380    // ---- api_key_auth: query-param percent-decoded match ----
14381
14382    #[tokio::test]
14383    async fn api_key_query_param_with_percent_encoded_chars_matches() {
14384        // Key contains '+' which must be percent-encoded as %2B in the
14385        // query string. The middleware decodes before comparison
14386        // (ultrareview #337) so the encoded form must still match.
14387        let app = auth_app(Some("a+b"));
14388        let resp = app
14389            .oneshot(
14390                axum::http::Request::builder()
14391                    .uri("/api/v1/memories?api_key=a%2Bb")
14392                    .body(Body::empty())
14393                    .unwrap(),
14394            )
14395            .await
14396            .unwrap();
14397        assert_eq!(resp.status(), StatusCode::OK);
14398    }
14399
14400    #[tokio::test]
14401    async fn api_key_query_param_wrong_value_rejected() {
14402        let app = auth_app(Some("secret"));
14403        let resp = app
14404            .oneshot(
14405                axum::http::Request::builder()
14406                    .uri("/api/v1/memories?api_key=wrong")
14407                    .body(Body::empty())
14408                    .unwrap(),
14409            )
14410            .await
14411            .unwrap();
14412        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
14413    }
14414
14415    #[tokio::test]
14416    async fn api_key_query_param_with_other_pairs_still_matches() {
14417        // Non-`api_key=` pairs in the query string don't disturb the
14418        // match — the middleware iterates pairs and only inspects
14419        // `api_key=`.
14420        let app = auth_app(Some("secret"));
14421        let resp = app
14422            .oneshot(
14423                axum::http::Request::builder()
14424                    .uri("/api/v1/memories?other=val&api_key=secret&trailing=x")
14425                    .body(Body::empty())
14426                    .unwrap(),
14427            )
14428            .await
14429            .unwrap();
14430        assert_eq!(resp.status(), StatusCode::OK);
14431    }
14432
14433    #[tokio::test]
14434    async fn api_key_header_with_invalid_utf8_falls_through() {
14435        // Header bytes that aren't valid UTF-8 fail `to_str()` and the
14436        // middleware moves on to the query check. Without a query match
14437        // the result is 401.
14438        let app = auth_app(Some("secret"));
14439        // HeaderValue::from_bytes accepts all bytes, but to_str rejects non-UTF8.
14440        let bytes = [0x80u8, 0x81u8];
14441        let req = axum::http::Request::builder()
14442            .uri("/api/v1/memories")
14443            .header(
14444                "x-api-key",
14445                axum::http::HeaderValue::from_bytes(&bytes).unwrap(),
14446            )
14447            .body(Body::empty())
14448            .unwrap();
14449        let resp = app.oneshot(req).await.unwrap();
14450        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
14451    }
14452
14453    // ---- /api/v1/health route via Router ----
14454
14455    #[tokio::test]
14456    async fn http_health_route_returns_200_with_status_ok() {
14457        let state = test_state();
14458        let app = Router::new()
14459            .route("/api/v1/health", axum_get(health))
14460            .with_state(test_app_state(state));
14461        let resp = app
14462            .oneshot(
14463                axum::http::Request::builder()
14464                    .uri("/api/v1/health")
14465                    .body(Body::empty())
14466                    .unwrap(),
14467            )
14468            .await
14469            .unwrap();
14470        assert_eq!(resp.status(), StatusCode::OK);
14471        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14472            .await
14473            .unwrap();
14474        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14475        assert_eq!(v["status"], "ok");
14476        assert_eq!(v["service"], "ai-memory");
14477        // The handler reports embedder_ready and federation_enabled
14478        // straight from the AppState wiring — both false in this test.
14479        assert_eq!(v["embedder_ready"], false);
14480        assert_eq!(v["federation_enabled"], false);
14481    }
14482
14483    // ---- prometheus_metrics happy path ----
14484
14485    #[tokio::test]
14486    async fn http_prometheus_metrics_returns_text_body() {
14487        let state = test_state();
14488        let app = Router::new()
14489            .route("/api/v1/metrics", axum_get(prometheus_metrics))
14490            .with_state(state);
14491        let resp = app
14492            .oneshot(
14493                axum::http::Request::builder()
14494                    .uri("/api/v1/metrics")
14495                    .body(Body::empty())
14496                    .unwrap(),
14497            )
14498            .await
14499            .unwrap();
14500        assert_eq!(resp.status(), StatusCode::OK);
14501        // Prometheus exposition starts with a `#` comment line; whatever
14502        // the renderer emits, we just confirm the body is non-empty.
14503        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14504            .await
14505            .unwrap();
14506        assert!(!bytes.is_empty());
14507    }
14508
14509    // ---- list_namespaces with seeded data ----
14510
14511    #[tokio::test]
14512    async fn http_list_namespaces_returns_seeded_namespaces() {
14513        let state = test_state();
14514        let _ = insert_test_memory(&state, "ns-foo", "t1").await;
14515        let _ = insert_test_memory(&state, "ns-bar", "t2").await;
14516        let app = Router::new()
14517            .route("/api/v1/namespaces", axum_get(list_namespaces))
14518            .with_state(state);
14519        let resp = app
14520            .oneshot(
14521                axum::http::Request::builder()
14522                    .uri("/api/v1/namespaces")
14523                    .body(Body::empty())
14524                    .unwrap(),
14525            )
14526            .await
14527            .unwrap();
14528        assert_eq!(resp.status(), StatusCode::OK);
14529        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14530            .await
14531            .unwrap();
14532        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14533        let ns = v["namespaces"].as_array().expect("namespaces array");
14534        assert!(!ns.is_empty());
14535    }
14536
14537    // ---- get_taxonomy variants ----
14538
14539    #[tokio::test]
14540    async fn http_get_taxonomy_no_prefix_returns_tree() {
14541        let state = test_state();
14542        let _ = insert_test_memory(&state, "tax/a", "t1").await;
14543        let _ = insert_test_memory(&state, "tax/b", "t2").await;
14544        let app = Router::new()
14545            .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14546            .with_state(state);
14547        let resp = app
14548            .oneshot(
14549                axum::http::Request::builder()
14550                    .uri("/api/v1/taxonomy")
14551                    .body(Body::empty())
14552                    .unwrap(),
14553            )
14554            .await
14555            .unwrap();
14556        assert_eq!(resp.status(), StatusCode::OK);
14557        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14558            .await
14559            .unwrap();
14560        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14561        assert!(v["tree"].is_array() || v["tree"].is_object());
14562    }
14563
14564    #[tokio::test]
14565    async fn http_get_taxonomy_invalid_prefix_returns_400() {
14566        let state = test_state();
14567        let app = Router::new()
14568            .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14569            .with_state(state);
14570        // A namespace prefix that ends with `/` after trimming the
14571        // trailing `/` and segments (e.g. `foo//bar`) fails
14572        // validate_namespace on the empty-segment check. The handler
14573        // first trims the trailing `/`, so to actually fail we need
14574        // an empty interior segment.
14575        let resp = app
14576            .oneshot(
14577                axum::http::Request::builder()
14578                    .uri("/api/v1/taxonomy?prefix=foo%2F%2Fbar")
14579                    .body(Body::empty())
14580                    .unwrap(),
14581            )
14582            .await
14583            .unwrap();
14584        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14585    }
14586
14587    #[tokio::test]
14588    async fn http_get_taxonomy_with_depth_and_limit() {
14589        let state = test_state();
14590        let _ = insert_test_memory(&state, "tax2/a/b", "t").await;
14591        let app = Router::new()
14592            .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14593            .with_state(state);
14594        let resp = app
14595            .oneshot(
14596                axum::http::Request::builder()
14597                    .uri("/api/v1/taxonomy?prefix=tax2&depth=4&limit=100")
14598                    .body(Body::empty())
14599                    .unwrap(),
14600            )
14601            .await
14602            .unwrap();
14603        assert_eq!(resp.status(), StatusCode::OK);
14604    }
14605
14606    // ---- get_memory edge cases ----
14607
14608    #[tokio::test]
14609    async fn http_get_memory_invalid_id_returns_400() {
14610        let state = test_state();
14611        let app = Router::new()
14612            .route("/api/v1/memories/{id}", axum_get(get_memory))
14613            .with_state(state);
14614        // Oversized id (>MAX_ID_LEN=128 bytes) fails validate_id.
14615        let big = "a".repeat(200);
14616        let resp = app
14617            .oneshot(
14618                axum::http::Request::builder()
14619                    .uri(format!("/api/v1/memories/{big}"))
14620                    .body(Body::empty())
14621                    .unwrap(),
14622            )
14623            .await
14624            .unwrap();
14625        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14626    }
14627
14628    #[tokio::test]
14629    async fn http_get_memory_unknown_id_returns_404() {
14630        let state = test_state();
14631        let app = Router::new()
14632            .route("/api/v1/memories/{id}", axum_get(get_memory))
14633            .with_state(state);
14634        // 32-char hex never inserted.
14635        let id = "deadbeefdeadbeefdeadbeefdeadbeef";
14636        let resp = app
14637            .oneshot(
14638                axum::http::Request::builder()
14639                    .uri(format!("/api/v1/memories/{id}"))
14640                    .body(Body::empty())
14641                    .unwrap(),
14642            )
14643            .await
14644            .unwrap();
14645        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14646    }
14647
14648    #[tokio::test]
14649    async fn http_get_memory_after_insert_returns_payload() {
14650        let state = test_state();
14651        let id = insert_test_memory(&state, "ns-get", "t-get").await;
14652        let app = Router::new()
14653            .route("/api/v1/memories/{id}", axum_get(get_memory))
14654            .with_state(state);
14655        let resp = app
14656            .oneshot(
14657                axum::http::Request::builder()
14658                    .uri(format!("/api/v1/memories/{id}"))
14659                    .body(Body::empty())
14660                    .unwrap(),
14661            )
14662            .await
14663            .unwrap();
14664        assert_eq!(resp.status(), StatusCode::OK);
14665        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14666            .await
14667            .unwrap();
14668        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14669        assert_eq!(v["memory"]["id"], id);
14670        assert!(v["links"].is_array());
14671    }
14672
14673    // ---- delete_memory edge cases (no governance, no federation) ----
14674
14675    #[tokio::test]
14676    async fn http_delete_memory_invalid_id_returns_400() {
14677        let state = test_state();
14678        let app = Router::new()
14679            .route(
14680                "/api/v1/memories/{id}",
14681                axum::routing::delete(delete_memory),
14682            )
14683            .with_state(test_app_state(state));
14684        let big = "b".repeat(200);
14685        let resp = app
14686            .oneshot(
14687                axum::http::Request::builder()
14688                    .uri(format!("/api/v1/memories/{big}"))
14689                    .method("DELETE")
14690                    .body(Body::empty())
14691                    .unwrap(),
14692            )
14693            .await
14694            .unwrap();
14695        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14696    }
14697
14698    #[tokio::test]
14699    async fn http_delete_memory_unknown_id_returns_404() {
14700        let state = test_state();
14701        let app = Router::new()
14702            .route(
14703                "/api/v1/memories/{id}",
14704                axum::routing::delete(delete_memory),
14705            )
14706            .with_state(test_app_state(state));
14707        let id = "cafebabecafebabecafebabecafebabe";
14708        let resp = app
14709            .oneshot(
14710                axum::http::Request::builder()
14711                    .uri(format!("/api/v1/memories/{id}"))
14712                    .method("DELETE")
14713                    .body(Body::empty())
14714                    .unwrap(),
14715            )
14716            .await
14717            .unwrap();
14718        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14719    }
14720
14721    #[tokio::test]
14722    async fn http_delete_memory_happy_path_returns_deleted_true() {
14723        let state = test_state();
14724        let id = insert_test_memory(&state, "ns-del", "t-del").await;
14725        let app = Router::new()
14726            .route(
14727                "/api/v1/memories/{id}",
14728                axum::routing::delete(delete_memory),
14729            )
14730            .with_state(test_app_state(state));
14731        let resp = app
14732            .oneshot(
14733                axum::http::Request::builder()
14734                    .uri(format!("/api/v1/memories/{id}"))
14735                    .method("DELETE")
14736                    .body(Body::empty())
14737                    .unwrap(),
14738            )
14739            .await
14740            .unwrap();
14741        assert_eq!(resp.status(), StatusCode::OK);
14742        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14743            .await
14744            .unwrap();
14745        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14746        assert_eq!(v["deleted"], true);
14747    }
14748
14749    #[tokio::test]
14750    async fn http_delete_memory_invalid_x_agent_id_returns_400() {
14751        let state = test_state();
14752        let id = insert_test_memory(&state, "ns-del-bad", "t").await;
14753        let app = Router::new()
14754            .route(
14755                "/api/v1/memories/{id}",
14756                axum::routing::delete(delete_memory),
14757            )
14758            .with_state(test_app_state(state));
14759        // Header value with a literal space fails validate_agent_id.
14760        let resp = app
14761            .oneshot(
14762                axum::http::Request::builder()
14763                    .uri(format!("/api/v1/memories/{id}"))
14764                    .method("DELETE")
14765                    .header("x-agent-id", "bad agent id")
14766                    .body(Body::empty())
14767                    .unwrap(),
14768            )
14769            .await
14770            .unwrap();
14771        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14772    }
14773
14774    // ---- promote_memory edge cases ----
14775
14776    #[tokio::test]
14777    async fn http_promote_memory_invalid_id_returns_400() {
14778        let state = test_state();
14779        let app = Router::new()
14780            .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14781            .with_state(test_app_state(state));
14782        let big = "p".repeat(200);
14783        let resp = app
14784            .oneshot(
14785                axum::http::Request::builder()
14786                    .uri(format!("/api/v1/memories/{big}/promote"))
14787                    .method("POST")
14788                    .body(Body::empty())
14789                    .unwrap(),
14790            )
14791            .await
14792            .unwrap();
14793        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14794    }
14795
14796    #[tokio::test]
14797    async fn http_promote_memory_unknown_id_returns_404() {
14798        let state = test_state();
14799        let app = Router::new()
14800            .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14801            .with_state(test_app_state(state));
14802        let id = "facefacefacefacefacefacefaceface";
14803        let resp = app
14804            .oneshot(
14805                axum::http::Request::builder()
14806                    .uri(format!("/api/v1/memories/{id}/promote"))
14807                    .method("POST")
14808                    .body(Body::empty())
14809                    .unwrap(),
14810            )
14811            .await
14812            .unwrap();
14813        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14814    }
14815
14816    #[tokio::test]
14817    async fn http_promote_memory_happy_path_clears_expires_at() {
14818        let state = test_state();
14819        // Insert a short-tier memory with expires_at set.
14820        let id = {
14821            let lock = state.lock().await;
14822            let now = Utc::now();
14823            let mem = Memory {
14824                id: Uuid::new_v4().to_string(),
14825                tier: Tier::Short,
14826                namespace: "ns-promote".into(),
14827                title: "to-promote".into(),
14828                content: "content".into(),
14829                tags: vec![],
14830                priority: 5,
14831                confidence: 1.0,
14832                source: "test".into(),
14833                access_count: 0,
14834                created_at: now.to_rfc3339(),
14835                updated_at: now.to_rfc3339(),
14836                last_accessed_at: None,
14837                expires_at: Some((now + Duration::seconds(3600)).to_rfc3339()),
14838                metadata: serde_json::json!({}),
14839            };
14840            db::insert(&lock.0, &mem).unwrap()
14841        };
14842        let app = Router::new()
14843            .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14844            .with_state(test_app_state(state.clone()));
14845        let resp = app
14846            .oneshot(
14847                axum::http::Request::builder()
14848                    .uri(format!("/api/v1/memories/{id}/promote"))
14849                    .method("POST")
14850                    .body(Body::empty())
14851                    .unwrap(),
14852            )
14853            .await
14854            .unwrap();
14855        assert_eq!(resp.status(), StatusCode::OK);
14856        // Confirm tier=long and expires_at cleared in the DB.
14857        let lock = state.lock().await;
14858        let m = db::get(&lock.0, &id).unwrap().unwrap();
14859        assert_eq!(m.tier, Tier::Long);
14860        assert!(m.expires_at.is_none());
14861    }
14862
14863    // ---- update_memory edge cases ----
14864
14865    #[tokio::test]
14866    async fn http_update_memory_unknown_id_returns_404() {
14867        let state = test_state();
14868        let app = Router::new()
14869            .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
14870            .with_state(test_app_state(state));
14871        let id = "1234567812345678123456781234567a";
14872        let body = serde_json::json!({"title": "new title"});
14873        let resp = app
14874            .oneshot(
14875                axum::http::Request::builder()
14876                    .uri(format!("/api/v1/memories/{id}"))
14877                    .method("PUT")
14878                    .header("content-type", "application/json")
14879                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
14880                    .unwrap(),
14881            )
14882            .await
14883            .unwrap();
14884        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14885    }
14886
14887    #[tokio::test]
14888    async fn http_update_memory_happy_path_returns_updated_payload() {
14889        let state = test_state();
14890        let id = insert_test_memory(&state, "ns-upd", "old title").await;
14891        let app = Router::new()
14892            .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
14893            .with_state(test_app_state(state.clone()));
14894        let body = serde_json::json!({"title": "new title", "content": "new content"});
14895        let resp = app
14896            .oneshot(
14897                axum::http::Request::builder()
14898                    .uri(format!("/api/v1/memories/{id}"))
14899                    .method("PUT")
14900                    .header("content-type", "application/json")
14901                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
14902                    .unwrap(),
14903            )
14904            .await
14905            .unwrap();
14906        assert_eq!(resp.status(), StatusCode::OK);
14907        let lock = state.lock().await;
14908        let m = db::get(&lock.0, &id).unwrap().unwrap();
14909        assert_eq!(m.title, "new title");
14910        assert_eq!(m.content, "new content");
14911    }
14912
14913    // ---- create_link / delete_link / get_links happy paths ----
14914
14915    #[tokio::test]
14916    async fn http_create_link_happy_path_returns_201() {
14917        let state = test_state();
14918        let src = insert_test_memory(&state, "ns-link", "src").await;
14919        let tgt = insert_test_memory(&state, "ns-link", "tgt").await;
14920        let app = Router::new()
14921            .route("/api/v1/links", axum_post(create_link))
14922            .with_state(test_app_state(state));
14923        let body = serde_json::json!({
14924            "source_id": src,
14925            "target_id": tgt,
14926            "relation": "related_to",
14927        });
14928        let resp = app
14929            .oneshot(
14930                axum::http::Request::builder()
14931                    .uri("/api/v1/links")
14932                    .method("POST")
14933                    .header("content-type", "application/json")
14934                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
14935                    .unwrap(),
14936            )
14937            .await
14938            .unwrap();
14939        assert_eq!(resp.status(), StatusCode::CREATED);
14940        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14941            .await
14942            .unwrap();
14943        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14944        assert_eq!(v["linked"], true);
14945    }
14946
14947    #[tokio::test]
14948    async fn http_create_link_invalid_link_returns_400() {
14949        let state = test_state();
14950        let app = Router::new()
14951            .route("/api/v1/links", axum_post(create_link))
14952            .with_state(test_app_state(state));
14953        // self-link is rejected by validate_link
14954        let body = serde_json::json!({
14955            "source_id": "abc",
14956            "target_id": "abc",
14957            "relation": "related_to",
14958        });
14959        let resp = app
14960            .oneshot(
14961                axum::http::Request::builder()
14962                    .uri("/api/v1/links")
14963                    .method("POST")
14964                    .header("content-type", "application/json")
14965                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
14966                    .unwrap(),
14967            )
14968            .await
14969            .unwrap();
14970        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14971    }
14972
14973    #[tokio::test]
14974    async fn http_get_links_invalid_id_returns_400() {
14975        let state = test_state();
14976        let app = Router::new()
14977            .route("/api/v1/memories/{id}/links", axum_get(get_links))
14978            .with_state(state);
14979        let big = "x".repeat(200);
14980        let resp = app
14981            .oneshot(
14982                axum::http::Request::builder()
14983                    .uri(format!("/api/v1/memories/{big}/links"))
14984                    .body(Body::empty())
14985                    .unwrap(),
14986            )
14987            .await
14988            .unwrap();
14989        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14990    }
14991
14992    #[tokio::test]
14993    async fn http_get_links_after_create_returns_link() {
14994        let state = test_state();
14995        let src = insert_test_memory(&state, "ns-getlinks", "src").await;
14996        let tgt = insert_test_memory(&state, "ns-getlinks", "tgt").await;
14997        {
14998            let lock = state.lock().await;
14999            db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15000        }
15001        let app = Router::new()
15002            .route("/api/v1/memories/{id}/links", axum_get(get_links))
15003            .with_state(state);
15004        let resp = app
15005            .oneshot(
15006                axum::http::Request::builder()
15007                    .uri(format!("/api/v1/memories/{src}/links"))
15008                    .body(Body::empty())
15009                    .unwrap(),
15010            )
15011            .await
15012            .unwrap();
15013        assert_eq!(resp.status(), StatusCode::OK);
15014        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15015            .await
15016            .unwrap();
15017        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15018        let links = v["links"].as_array().expect("links array");
15019        assert!(!links.is_empty());
15020    }
15021
15022    #[tokio::test]
15023    async fn http_delete_link_after_create_returns_deleted_true() {
15024        let state = test_state();
15025        let src = insert_test_memory(&state, "ns-dellink", "src").await;
15026        let tgt = insert_test_memory(&state, "ns-dellink", "tgt").await;
15027        {
15028            let lock = state.lock().await;
15029            db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15030        }
15031        let app = Router::new()
15032            .route("/api/v1/links", axum::routing::delete(delete_link))
15033            .with_state(test_app_state(state));
15034        let body = serde_json::json!({
15035            "source_id": src,
15036            "target_id": tgt,
15037            "relation": "related_to",
15038        });
15039        let resp = app
15040            .oneshot(
15041                axum::http::Request::builder()
15042                    .uri("/api/v1/links")
15043                    .method("DELETE")
15044                    .header("content-type", "application/json")
15045                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15046                    .unwrap(),
15047            )
15048            .await
15049            .unwrap();
15050        assert_eq!(resp.status(), StatusCode::OK);
15051        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15052            .await
15053            .unwrap();
15054        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15055        assert_eq!(v["deleted"], true);
15056    }
15057
15058    // ---- get_stats / run_gc / export_memories happy paths ----
15059
15060    #[tokio::test]
15061    async fn http_get_stats_with_data_returns_total() {
15062        let state = test_state();
15063        let _ = insert_test_memory(&state, "ns-stats", "t1").await;
15064        let _ = insert_test_memory(&state, "ns-stats", "t2").await;
15065        let app = Router::new()
15066            .route("/api/v1/stats", axum_get(get_stats))
15067            .with_state(state);
15068        let resp = app
15069            .oneshot(
15070                axum::http::Request::builder()
15071                    .uri("/api/v1/stats")
15072                    .body(Body::empty())
15073                    .unwrap(),
15074            )
15075            .await
15076            .unwrap();
15077        assert_eq!(resp.status(), StatusCode::OK);
15078        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15079            .await
15080            .unwrap();
15081        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15082        assert_eq!(v["total"], 2);
15083    }
15084
15085    #[tokio::test]
15086    async fn http_export_memories_with_data_returns_count() {
15087        let state = test_state();
15088        let _ = insert_test_memory(&state, "ns-export", "t1").await;
15089        let _ = insert_test_memory(&state, "ns-export", "t2").await;
15090        let app = Router::new()
15091            .route("/api/v1/export", axum_get(export_memories))
15092            .with_state(state);
15093        let resp = app
15094            .oneshot(
15095                axum::http::Request::builder()
15096                    .uri("/api/v1/export")
15097                    .body(Body::empty())
15098                    .unwrap(),
15099            )
15100            .await
15101            .unwrap();
15102        assert_eq!(resp.status(), StatusCode::OK);
15103        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
15104            .await
15105            .unwrap();
15106        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15107        assert_eq!(v["count"], 2);
15108        assert!(v["exported_at"].is_string());
15109    }
15110
15111    // ---- import_memories happy path ----
15112
15113    #[tokio::test]
15114    async fn http_import_memories_inserts_valid_rows() {
15115        let state = test_state();
15116        let app = Router::new()
15117            .route("/api/v1/import", axum_post(import_memories))
15118            .with_state(state);
15119        let now = Utc::now().to_rfc3339();
15120        let mem = serde_json::json!({
15121            "id": Uuid::new_v4().to_string(),
15122            "tier": "long",
15123            "namespace": "imported",
15124            "title": "imported-row",
15125            "content": "imported content",
15126            "tags": [],
15127            "priority": 5,
15128            "confidence": 1.0,
15129            "source": "import",
15130            "access_count": 0,
15131            "created_at": now,
15132            "updated_at": now,
15133            "last_accessed_at": null,
15134            "expires_at": null,
15135            "metadata": {},
15136        });
15137        let body = serde_json::json!({"memories": [mem]});
15138        let resp = app
15139            .oneshot(
15140                axum::http::Request::builder()
15141                    .uri("/api/v1/import")
15142                    .method("POST")
15143                    .header("content-type", "application/json")
15144                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15145                    .unwrap(),
15146            )
15147            .await
15148            .unwrap();
15149        assert_eq!(resp.status(), StatusCode::OK);
15150        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15151            .await
15152            .unwrap();
15153        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15154        assert_eq!(v["imported"], 1);
15155    }
15156
15157    // ---- recall edge cases ----
15158
15159    #[tokio::test]
15160    async fn http_recall_get_invalid_as_agent_returns_400() {
15161        let state = test_state();
15162        let app = Router::new()
15163            .route("/api/v1/recall", axum_get(recall_memories_get))
15164            .with_state(test_app_state(state));
15165        // as_agent goes through validate_namespace which rejects spaces.
15166        let resp = app
15167            .oneshot(
15168                axum::http::Request::builder()
15169                    .uri("/api/v1/recall?context=hello&as_agent=bad%20agent")
15170                    .body(Body::empty())
15171                    .unwrap(),
15172            )
15173            .await
15174            .unwrap();
15175        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15176    }
15177
15178    #[tokio::test]
15179    async fn http_recall_post_invalid_as_agent_returns_400() {
15180        let state = test_state();
15181        let app = Router::new()
15182            .route("/api/v1/recall", axum_post(recall_memories_post))
15183            .with_state(test_app_state(state));
15184        let body = serde_json::json!({"context": "x", "as_agent": "bad agent"});
15185        let resp = app
15186            .oneshot(
15187                axum::http::Request::builder()
15188                    .uri("/api/v1/recall")
15189                    .method("POST")
15190                    .header("content-type", "application/json")
15191                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15192                    .unwrap(),
15193            )
15194            .await
15195            .unwrap();
15196        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15197    }
15198
15199    #[tokio::test]
15200    async fn http_recall_post_zero_budget_tokens_returns_200() {
15201        // Phase P6 (R1): budget_tokens=0 returns 200 with an empty
15202        // memories list — see recall_post_zero_budget_tokens_returns_empty
15203        // for the matching unit-tested handler-level test.
15204        let state = test_state();
15205        let app = Router::new()
15206            .route("/api/v1/recall", axum_post(recall_memories_post))
15207            .with_state(test_app_state(state));
15208        let body = serde_json::json!({"context": "x", "budget_tokens": 0});
15209        let resp = app
15210            .oneshot(
15211                axum::http::Request::builder()
15212                    .uri("/api/v1/recall")
15213                    .method("POST")
15214                    .header("content-type", "application/json")
15215                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15216                    .unwrap(),
15217            )
15218            .await
15219            .unwrap();
15220        assert_eq!(resp.status(), StatusCode::OK);
15221    }
15222
15223    // ---- search_memories with as_agent invalid ----
15224
15225    #[tokio::test]
15226    async fn http_search_invalid_as_agent_returns_400() {
15227        let state = test_state();
15228        let app = Router::new()
15229            .route("/api/v1/search", axum_get(search_memories))
15230            .with_state(state);
15231        // validate_namespace rejects spaces.
15232        let resp = app
15233            .oneshot(
15234                axum::http::Request::builder()
15235                    .uri("/api/v1/search?q=hello&as_agent=bad%20agent")
15236                    .body(Body::empty())
15237                    .unwrap(),
15238            )
15239            .await
15240            .unwrap();
15241        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15242    }
15243
15244    // ---- forget_memories happy and noop ----
15245
15246    #[tokio::test]
15247    async fn http_forget_memories_with_nothing_to_match_returns_zero() {
15248        let state = test_state();
15249        let app = Router::new()
15250            .route("/api/v1/forget", axum_post(forget_memories))
15251            .with_state(state);
15252        let body = serde_json::json!({"namespace": "no-such-ns"});
15253        let resp = app
15254            .oneshot(
15255                axum::http::Request::builder()
15256                    .uri("/api/v1/forget")
15257                    .method("POST")
15258                    .header("content-type", "application/json")
15259                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15260                    .unwrap(),
15261            )
15262            .await
15263            .unwrap();
15264        assert_eq!(resp.status(), StatusCode::OK);
15265        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15266            .await
15267            .unwrap();
15268        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15269        assert_eq!(v["deleted"], 0);
15270    }
15271
15272    // ---- run_gc happy ----
15273
15274    #[tokio::test]
15275    async fn http_run_gc_after_insert_returns_zero_when_nothing_expired() {
15276        let state = test_state();
15277        let _ = insert_test_memory(&state, "gc-ns", "title").await;
15278        let app = Router::new()
15279            .route("/api/v1/gc", axum_post(run_gc))
15280            .with_state(state);
15281        let resp = app
15282            .oneshot(
15283                axum::http::Request::builder()
15284                    .uri("/api/v1/gc")
15285                    .method("POST")
15286                    .body(Body::empty())
15287                    .unwrap(),
15288            )
15289            .await
15290            .unwrap();
15291        assert_eq!(resp.status(), StatusCode::OK);
15292        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15293            .await
15294            .unwrap();
15295        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15296        assert_eq!(v["expired_deleted"], 0);
15297    }
15298
15299    // ---- list_pending limit clamp + happy ----
15300
15301    #[tokio::test]
15302    async fn http_list_pending_default_limit_returns_count_zero_for_empty() {
15303        let state = test_state();
15304        let app = Router::new()
15305            .route("/api/v1/pending", axum_get(list_pending))
15306            .with_state(state);
15307        let resp = app
15308            .oneshot(
15309                axum::http::Request::builder()
15310                    .uri("/api/v1/pending")
15311                    .body(Body::empty())
15312                    .unwrap(),
15313            )
15314            .await
15315            .unwrap();
15316        assert_eq!(resp.status(), StatusCode::OK);
15317        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15318            .await
15319            .unwrap();
15320        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15321        assert_eq!(v["count"], 0);
15322    }
15323
15324    // ---- restore_archive edge cases (no federation) ----
15325
15326    #[tokio::test]
15327    async fn http_restore_archive_invalid_id_returns_400() {
15328        let state = test_state();
15329        let app = Router::new()
15330            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15331            .with_state(test_app_state(state));
15332        let big = "r".repeat(200);
15333        let resp = app
15334            .oneshot(
15335                axum::http::Request::builder()
15336                    .uri(format!("/api/v1/archive/{big}/restore"))
15337                    .method("POST")
15338                    .body(Body::empty())
15339                    .unwrap(),
15340            )
15341            .await
15342            .unwrap();
15343        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15344    }
15345
15346    #[tokio::test]
15347    async fn http_restore_archive_unknown_id_returns_404() {
15348        let state = test_state();
15349        let app = Router::new()
15350            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15351            .with_state(test_app_state(state));
15352        let id = "0123456701234567012345670123456a";
15353        let resp = app
15354            .oneshot(
15355                axum::http::Request::builder()
15356                    .uri(format!("/api/v1/archive/{id}/restore"))
15357                    .method("POST")
15358                    .body(Body::empty())
15359                    .unwrap(),
15360            )
15361            .await
15362            .unwrap();
15363        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
15364    }
15365
15366    #[tokio::test]
15367    async fn http_restore_archive_happy_path_returns_restored_true() {
15368        let state = test_state();
15369        let id = insert_test_memory(&state, "ns-restore", "row").await;
15370        {
15371            let lock = state.lock().await;
15372            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
15373        }
15374        let app = Router::new()
15375            .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15376            .with_state(test_app_state(state));
15377        let resp = app
15378            .oneshot(
15379                axum::http::Request::builder()
15380                    .uri(format!("/api/v1/archive/{id}/restore"))
15381                    .method("POST")
15382                    .body(Body::empty())
15383                    .unwrap(),
15384            )
15385            .await
15386            .unwrap();
15387        assert_eq!(resp.status(), StatusCode::OK);
15388        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15389            .await
15390            .unwrap();
15391        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15392        assert_eq!(v["restored"], true);
15393    }
15394
15395    // ---- entity_get_by_alias edge cases ----
15396
15397    #[tokio::test]
15398    async fn http_entity_get_by_alias_with_namespace_filter_returns_found_false() {
15399        let state = test_state();
15400        let app = Router::new()
15401            .route("/api/v1/entities/by_alias", axum_get(entity_get_by_alias))
15402            .with_state(state);
15403        let resp = app
15404            .oneshot(
15405                axum::http::Request::builder()
15406                    .uri("/api/v1/entities/by_alias?alias=Acme&namespace=corp")
15407                    .body(Body::empty())
15408                    .unwrap(),
15409            )
15410            .await
15411            .unwrap();
15412        assert_eq!(resp.status(), StatusCode::OK);
15413        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15414            .await
15415            .unwrap();
15416        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15417        assert_eq!(v["found"], false);
15418    }
15419
15420    // ---- kg_timeline returns_empty_for_unlinked_source covered, add since/until variants ----
15421
15422    #[tokio::test]
15423    async fn http_kg_timeline_with_valid_since_and_until_succeeds() {
15424        let state = test_state();
15425        let id = insert_test_memory(&state, "kg-tl", "src").await;
15426        let app = Router::new()
15427            .route("/api/v1/kg/timeline", axum_get(kg_timeline))
15428            .with_state(state);
15429        let resp = app
15430            .oneshot(
15431                axum::http::Request::builder()
15432                    .uri(format!(
15433                        "/api/v1/kg/timeline?source_id={id}&since=2020-01-01T00:00:00Z&until=2030-01-01T00:00:00Z&limit=100"
15434                    ))
15435                    .body(Body::empty())
15436                    .unwrap(),
15437            )
15438            .await
15439            .unwrap();
15440        assert_eq!(resp.status(), StatusCode::OK);
15441    }
15442
15443    // ---- session_start happy path ----
15444
15445    #[tokio::test]
15446    async fn http_session_start_with_namespace_returns_session_id() {
15447        let state = test_state();
15448        let _ = insert_test_memory(&state, "session-ns", "row").await;
15449        let app = Router::new()
15450            .route("/api/v1/session/start", axum_post(session_start))
15451            .with_state(state);
15452        let body =
15453            serde_json::json!({"namespace": "session-ns", "limit": 5, "agent_id": "ai:tester"});
15454        let resp = app
15455            .oneshot(
15456                axum::http::Request::builder()
15457                    .uri("/api/v1/session/start")
15458                    .method("POST")
15459                    .header("content-type", "application/json")
15460                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15461                    .unwrap(),
15462            )
15463            .await
15464            .unwrap();
15465        assert_eq!(resp.status(), StatusCode::OK);
15466        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15467            .await
15468            .unwrap();
15469        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15470        assert!(v["session_id"].is_string());
15471        assert_eq!(v["agent_id"], "ai:tester");
15472    }
15473
15474    // ---- notify rejects empty payload+content ----
15475
15476    #[tokio::test]
15477    async fn http_notify_missing_payload_and_content_returns_400() {
15478        let state = test_state();
15479        let app = Router::new()
15480            .route("/api/v1/notify", axum_post(notify))
15481            .with_state(test_app_state(state));
15482        let body = serde_json::json!({
15483            "target_agent_id": "ai:bob",
15484            "title": "ping",
15485        });
15486        let resp = app
15487            .oneshot(
15488                axum::http::Request::builder()
15489                    .uri("/api/v1/notify")
15490                    .method("POST")
15491                    .header("x-agent-id", "ai:alice")
15492                    .header("content-type", "application/json")
15493                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15494                    .unwrap(),
15495            )
15496            .await
15497            .unwrap();
15498        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15499    }
15500
15501    #[tokio::test]
15502    async fn http_notify_with_payload_field_returns_201() {
15503        let state = test_state();
15504        // Pre-register sender so the inbox handler accepts the write.
15505        {
15506            let lock = state.lock().await;
15507            db::register_agent(&lock.0, "ai:alice", "ai:human", &[]).unwrap();
15508            db::register_agent(&lock.0, "ai:bob", "ai:human", &[]).unwrap();
15509        }
15510        let app = Router::new()
15511            .route("/api/v1/notify", axum_post(notify))
15512            .with_state(test_app_state(state));
15513        let body = serde_json::json!({
15514            "target_agent_id": "ai:bob",
15515            "title": "ping",
15516            "payload": "hi bob",
15517        });
15518        let resp = app
15519            .oneshot(
15520                axum::http::Request::builder()
15521                    .uri("/api/v1/notify")
15522                    .method("POST")
15523                    .header("x-agent-id", "ai:alice")
15524                    .header("content-type", "application/json")
15525                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15526                    .unwrap(),
15527            )
15528            .await
15529            .unwrap();
15530        assert_eq!(resp.status(), StatusCode::CREATED);
15531    }
15532
15533    // ---- subscribe / unsubscribe / list_subscriptions edge cases ----
15534
15535    #[tokio::test]
15536    async fn http_subscribe_missing_url_and_namespace_returns_400() {
15537        let state = test_state();
15538        let app = Router::new()
15539            .route("/api/v1/subscribe", axum_post(subscribe))
15540            .with_state(test_app_state(state));
15541        // Neither url nor namespace — handler rejects.
15542        let body = serde_json::json!({"agent_id": "ai:alice"});
15543        let resp = app
15544            .oneshot(
15545                axum::http::Request::builder()
15546                    .uri("/api/v1/subscribe")
15547                    .method("POST")
15548                    .header("content-type", "application/json")
15549                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15550                    .unwrap(),
15551            )
15552            .await
15553            .unwrap();
15554        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15555    }
15556
15557    #[tokio::test]
15558    async fn http_subscribe_with_namespace_synthesizes_loopback_url_and_returns_201() {
15559        let state = test_state();
15560        let app = Router::new()
15561            .route("/api/v1/subscribe", axum_post(subscribe))
15562            .with_state(test_app_state(state));
15563        let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice"});
15564        let resp = app
15565            .oneshot(
15566                axum::http::Request::builder()
15567                    .uri("/api/v1/subscribe")
15568                    .method("POST")
15569                    .header("content-type", "application/json")
15570                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15571                    .unwrap(),
15572            )
15573            .await
15574            .unwrap();
15575        assert_eq!(resp.status(), StatusCode::CREATED);
15576        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15577            .await
15578            .unwrap();
15579        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15580        assert_eq!(v["namespace"], "team/alice");
15581        assert_eq!(v["agent_id"], "ai:alice");
15582    }
15583
15584    #[tokio::test]
15585    async fn http_unsubscribe_missing_id_and_namespace_returns_400() {
15586        let state = test_state();
15587        let app = Router::new()
15588            .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
15589            .with_state(test_app_state(state));
15590        // x-agent-id header set; but neither id nor namespace — 400.
15591        let resp = app
15592            .oneshot(
15593                axum::http::Request::builder()
15594                    .uri("/api/v1/subscribe")
15595                    .method("DELETE")
15596                    .header("x-agent-id", "ai:alice")
15597                    .body(Body::empty())
15598                    .unwrap(),
15599            )
15600            .await
15601            .unwrap();
15602        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15603    }
15604
15605    #[tokio::test]
15606    async fn http_unsubscribe_by_agent_namespace_after_subscribe_returns_removed() {
15607        let state = test_state();
15608        // Subscribe via the handler so the row lands consistent with the
15609        // unsubscribe lookup.
15610        let sub_app = Router::new()
15611            .route("/api/v1/subscribe", axum_post(subscribe))
15612            .with_state(test_app_state(state.clone()));
15613        let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice"});
15614        let resp = sub_app
15615            .oneshot(
15616                axum::http::Request::builder()
15617                    .uri("/api/v1/subscribe")
15618                    .method("POST")
15619                    .header("content-type", "application/json")
15620                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15621                    .unwrap(),
15622            )
15623            .await
15624            .unwrap();
15625        assert_eq!(resp.status(), StatusCode::CREATED);
15626
15627        let app = Router::new()
15628            .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
15629            .with_state(test_app_state(state));
15630        let resp = app
15631            .oneshot(
15632                axum::http::Request::builder()
15633                    .uri("/api/v1/subscribe?agent_id=ai:alice&namespace=team/alice")
15634                    .method("DELETE")
15635                    .body(Body::empty())
15636                    .unwrap(),
15637            )
15638            .await
15639            .unwrap();
15640        assert_eq!(resp.status(), StatusCode::OK);
15641        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15642            .await
15643            .unwrap();
15644        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15645        assert_eq!(v["removed"], true);
15646    }
15647
15648    // ---- list_subscriptions baseline ----
15649
15650    #[tokio::test]
15651    async fn http_list_subscriptions_returns_subscription_rows() {
15652        let state = test_state();
15653        // Drop one subscription via the subscribe handler.
15654        let sub_app = Router::new()
15655            .route("/api/v1/subscribe", axum_post(subscribe))
15656            .with_state(test_app_state(state.clone()));
15657        let body = serde_json::json!({"agent_id": "ai:carol", "namespace": "team/carol"});
15658        let resp = sub_app
15659            .oneshot(
15660                axum::http::Request::builder()
15661                    .uri("/api/v1/subscribe")
15662                    .method("POST")
15663                    .header("content-type", "application/json")
15664                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15665                    .unwrap(),
15666            )
15667            .await
15668            .unwrap();
15669        assert_eq!(resp.status(), StatusCode::CREATED);
15670
15671        let app = Router::new()
15672            .route("/api/v1/subscriptions", axum_get(list_subscriptions))
15673            .with_state(state);
15674        let resp = app
15675            .oneshot(
15676                axum::http::Request::builder()
15677                    .uri("/api/v1/subscriptions")
15678                    .body(Body::empty())
15679                    .unwrap(),
15680            )
15681            .await
15682            .unwrap();
15683        assert_eq!(resp.status(), StatusCode::OK);
15684        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15685            .await
15686            .unwrap();
15687        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15688        assert!(v["count"].as_u64().unwrap() >= 1);
15689    }
15690
15691    // ---- kg_query happy path with results ----
15692
15693    #[tokio::test]
15694    async fn http_kg_query_after_create_link_returns_node() {
15695        let state = test_state();
15696        let src = insert_test_memory(&state, "kg-q", "src").await;
15697        let tgt = insert_test_memory(&state, "kg-q", "tgt").await;
15698        {
15699            let lock = state.lock().await;
15700            db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15701        }
15702        let app = Router::new()
15703            .route("/api/v1/kg/query", axum_post(kg_query))
15704            .with_state(state);
15705        let body = serde_json::json!({"source_id": src, "max_depth": 1, "limit": 10});
15706        let resp = app
15707            .oneshot(
15708                axum::http::Request::builder()
15709                    .uri("/api/v1/kg/query")
15710                    .method("POST")
15711                    .header("content-type", "application/json")
15712                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15713                    .unwrap(),
15714            )
15715            .await
15716            .unwrap();
15717        assert_eq!(resp.status(), StatusCode::OK);
15718        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15719            .await
15720            .unwrap();
15721        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15722        assert_eq!(v["source_id"], src);
15723        let mems = v["memories"].as_array().expect("memories array");
15724        assert!(!mems.is_empty());
15725    }
15726
15727    #[tokio::test]
15728    async fn http_kg_invalidate_round_trip_marks_link() {
15729        let state = test_state();
15730        let src = insert_test_memory(&state, "kg-inv", "src").await;
15731        let tgt = insert_test_memory(&state, "kg-inv", "tgt").await;
15732        {
15733            let lock = state.lock().await;
15734            db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15735        }
15736        let app = Router::new()
15737            .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
15738            .with_state(state);
15739        let body = serde_json::json!({
15740            "source_id": src,
15741            "target_id": tgt,
15742            "relation": "related_to",
15743            "valid_until": "2030-01-01T00:00:00Z",
15744        });
15745        let resp = app
15746            .oneshot(
15747                axum::http::Request::builder()
15748                    .uri("/api/v1/kg/invalidate")
15749                    .method("POST")
15750                    .header("content-type", "application/json")
15751                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15752                    .unwrap(),
15753            )
15754            .await
15755            .unwrap();
15756        assert_eq!(resp.status(), StatusCode::OK);
15757        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15758            .await
15759            .unwrap();
15760        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15761        assert_eq!(v["found"], true);
15762    }
15763
15764    // ---- list_archive happy with seeded data ----
15765
15766    #[tokio::test]
15767    async fn http_list_archive_returns_archived_rows() {
15768        let state = test_state();
15769        let id = insert_test_memory(&state, "ns-archive", "row").await;
15770        {
15771            let lock = state.lock().await;
15772            db::archive_memory(&lock.0, &id, Some("test")).unwrap();
15773        }
15774        let app = Router::new()
15775            .route("/api/v1/archive", axum_get(list_archive))
15776            .with_state(state);
15777        let resp = app
15778            .oneshot(
15779                axum::http::Request::builder()
15780                    .uri("/api/v1/archive?namespace=ns-archive&limit=10&offset=0")
15781                    .body(Body::empty())
15782                    .unwrap(),
15783            )
15784            .await
15785            .unwrap();
15786        assert_eq!(resp.status(), StatusCode::OK);
15787        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15788            .await
15789            .unwrap();
15790        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15791        assert!(v["count"].as_u64().unwrap() >= 1);
15792    }
15793
15794    // ---- archive_by_ids with reason field ----
15795
15796    #[tokio::test]
15797    async fn http_archive_by_ids_with_explicit_reason_records_it() {
15798        let state = test_state();
15799        let id = insert_test_memory(&state, "ns-arch", "row").await;
15800        let app = Router::new()
15801            .route("/api/v1/archive", axum_post(archive_by_ids))
15802            .with_state(test_app_state(state));
15803        let body = serde_json::json!({"ids": [id], "reason": "user requested"});
15804        let resp = app
15805            .oneshot(
15806                axum::http::Request::builder()
15807                    .uri("/api/v1/archive")
15808                    .method("POST")
15809                    .header("content-type", "application/json")
15810                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15811                    .unwrap(),
15812            )
15813            .await
15814            .unwrap();
15815        assert_eq!(resp.status(), StatusCode::OK);
15816        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15817            .await
15818            .unwrap();
15819        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15820        assert_eq!(v["reason"], "user requested");
15821        assert_eq!(v["count"], 1);
15822    }
15823
15824    // ---- sync_push: per-field oversize rejections (sweep all guards) ----
15825
15826    fn over_max_string_vec(n: usize) -> Vec<String> {
15827        (0..n).map(|i| format!("id-{i:040}")).collect()
15828    }
15829
15830    #[tokio::test]
15831    async fn http_sync_push_oversize_deletions_returns_400() {
15832        let state = test_state();
15833        let app = Router::new()
15834            .route("/api/v1/sync/push", axum_post(sync_push))
15835            .with_state(test_app_state(state));
15836        let body = serde_json::json!({
15837            "sender_agent_id": "ai:peer",
15838            "memories": [],
15839            "deletions": over_max_string_vec(MAX_BULK_SIZE + 1),
15840        });
15841        let resp = app
15842            .oneshot(
15843                axum::http::Request::builder()
15844                    .uri("/api/v1/sync/push")
15845                    .method("POST")
15846                    .header("content-type", "application/json")
15847                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15848                    .unwrap(),
15849            )
15850            .await
15851            .unwrap();
15852        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15853        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15854            .await
15855            .unwrap();
15856        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15857        assert!(
15858            v["error"]
15859                .as_str()
15860                .unwrap()
15861                .contains("deletions per request"),
15862            "{v:?}"
15863        );
15864    }
15865
15866    #[tokio::test]
15867    async fn http_sync_push_oversize_archives_returns_400() {
15868        let state = test_state();
15869        let app = Router::new()
15870            .route("/api/v1/sync/push", axum_post(sync_push))
15871            .with_state(test_app_state(state));
15872        let body = serde_json::json!({
15873            "sender_agent_id": "ai:peer",
15874            "memories": [],
15875            "archives": over_max_string_vec(MAX_BULK_SIZE + 1),
15876        });
15877        let resp = app
15878            .oneshot(
15879                axum::http::Request::builder()
15880                    .uri("/api/v1/sync/push")
15881                    .method("POST")
15882                    .header("content-type", "application/json")
15883                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15884                    .unwrap(),
15885            )
15886            .await
15887            .unwrap();
15888        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15889        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15890            .await
15891            .unwrap();
15892        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15893        assert!(v["error"].as_str().unwrap().contains("archives"));
15894    }
15895
15896    #[tokio::test]
15897    async fn http_sync_push_oversize_restores_returns_400() {
15898        let state = test_state();
15899        let app = Router::new()
15900            .route("/api/v1/sync/push", axum_post(sync_push))
15901            .with_state(test_app_state(state));
15902        let body = serde_json::json!({
15903            "sender_agent_id": "ai:peer",
15904            "memories": [],
15905            "restores": over_max_string_vec(MAX_BULK_SIZE + 1),
15906        });
15907        let resp = app
15908            .oneshot(
15909                axum::http::Request::builder()
15910                    .uri("/api/v1/sync/push")
15911                    .method("POST")
15912                    .header("content-type", "application/json")
15913                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15914                    .unwrap(),
15915            )
15916            .await
15917            .unwrap();
15918        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15919        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15920            .await
15921            .unwrap();
15922        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15923        assert!(v["error"].as_str().unwrap().contains("restores"));
15924    }
15925
15926    #[tokio::test]
15927    async fn http_sync_push_oversize_namespace_meta_clears_returns_400() {
15928        let state = test_state();
15929        let app = Router::new()
15930            .route("/api/v1/sync/push", axum_post(sync_push))
15931            .with_state(test_app_state(state));
15932        let body = serde_json::json!({
15933            "sender_agent_id": "ai:peer",
15934            "memories": [],
15935            "namespace_meta_clears": over_max_string_vec(MAX_BULK_SIZE + 1),
15936        });
15937        let resp = app
15938            .oneshot(
15939                axum::http::Request::builder()
15940                    .uri("/api/v1/sync/push")
15941                    .method("POST")
15942                    .header("content-type", "application/json")
15943                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15944                    .unwrap(),
15945            )
15946            .await
15947            .unwrap();
15948        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15949        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15950            .await
15951            .unwrap();
15952        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15953        assert!(
15954            v["error"]
15955                .as_str()
15956                .unwrap()
15957                .contains("namespace_meta_clears")
15958        );
15959    }
15960
15961    #[tokio::test]
15962    async fn http_sync_push_invalid_sender_agent_id_returns_400() {
15963        let state = test_state();
15964        let app = Router::new()
15965            .route("/api/v1/sync/push", axum_post(sync_push))
15966            .with_state(test_app_state(state));
15967        // Spaces aren't valid agent ids.
15968        let body = serde_json::json!({
15969            "sender_agent_id": "bad agent id",
15970            "memories": [],
15971        });
15972        let resp = app
15973            .oneshot(
15974                axum::http::Request::builder()
15975                    .uri("/api/v1/sync/push")
15976                    .method("POST")
15977                    .header("content-type", "application/json")
15978                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
15979                    .unwrap(),
15980            )
15981            .await
15982            .unwrap();
15983        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15984        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15985            .await
15986            .unwrap();
15987        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15988        assert!(v["error"].as_str().unwrap().contains("sender_agent_id"));
15989    }
15990
15991    #[tokio::test]
15992    async fn http_sync_push_invalid_x_agent_id_header_returns_400() {
15993        let state = test_state();
15994        let app = Router::new()
15995            .route("/api/v1/sync/push", axum_post(sync_push))
15996            .with_state(test_app_state(state));
15997        let body = serde_json::json!({
15998            "sender_agent_id": "ai:peer",
15999            "memories": [],
16000        });
16001        let resp = app
16002            .oneshot(
16003                axum::http::Request::builder()
16004                    .uri("/api/v1/sync/push")
16005                    .method("POST")
16006                    .header("content-type", "application/json")
16007                    .header("x-agent-id", "bad agent id")
16008                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16009                    .unwrap(),
16010            )
16011            .await
16012            .unwrap();
16013        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16014    }
16015
16016    // ---- sync_push: applies pending decisions and namespace_meta paths ----
16017
16018    #[tokio::test]
16019    async fn http_sync_push_pending_invalid_id_skipped() {
16020        let state = test_state();
16021        let app = Router::new()
16022            .route("/api/v1/sync/push", axum_post(sync_push))
16023            .with_state(test_app_state(state));
16024        let bad_id = "x".repeat(200); // exceeds MAX_ID_LEN
16025        let body = serde_json::json!({
16026            "sender_agent_id": "ai:peer",
16027            "memories": [],
16028            "pendings": [{
16029                "id": bad_id,
16030                "action_type": "store",
16031                "memory_id": null,
16032                "namespace": "ns",
16033                "payload": {},
16034                "requested_by": "ai:peer",
16035                "requested_at": "2024-01-01T00:00:00Z",
16036                "status": "pending",
16037                "approvals": [],
16038            }],
16039        });
16040        let resp = app
16041            .oneshot(
16042                axum::http::Request::builder()
16043                    .uri("/api/v1/sync/push")
16044                    .method("POST")
16045                    .header("content-type", "application/json")
16046                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16047                    .unwrap(),
16048            )
16049            .await
16050            .unwrap();
16051        assert_eq!(resp.status(), StatusCode::OK);
16052        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16053            .await
16054            .unwrap();
16055        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16056        assert_eq!(v["skipped"], 1);
16057        assert_eq!(v["pendings_applied"], 0);
16058    }
16059
16060    #[tokio::test]
16061    async fn http_sync_push_links_invalid_id_skipped() {
16062        let state = test_state();
16063        let app = Router::new()
16064            .route("/api/v1/sync/push", axum_post(sync_push))
16065            .with_state(test_app_state(state));
16066        // Self-link is invalid via validate_link.
16067        let body = serde_json::json!({
16068            "sender_agent_id": "ai:peer",
16069            "memories": [],
16070            "links": [{
16071                "source_id": "abc",
16072                "target_id": "abc",
16073                "relation": "related_to",
16074                "created_at": "2024-01-01T00:00:00Z",
16075            }],
16076        });
16077        let resp = app
16078            .oneshot(
16079                axum::http::Request::builder()
16080                    .uri("/api/v1/sync/push")
16081                    .method("POST")
16082                    .header("content-type", "application/json")
16083                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16084                    .unwrap(),
16085            )
16086            .await
16087            .unwrap();
16088        assert_eq!(resp.status(), StatusCode::OK);
16089        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16090            .await
16091            .unwrap();
16092        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16093        assert_eq!(v["skipped"], 1);
16094        assert_eq!(v["links_applied"], 0);
16095    }
16096
16097    #[tokio::test]
16098    async fn http_sync_push_dry_run_links_no_apply() {
16099        let state = test_state();
16100        let src = insert_test_memory(&state, "dryrun-links", "src").await;
16101        let tgt = insert_test_memory(&state, "dryrun-links", "tgt").await;
16102        let app = Router::new()
16103            .route("/api/v1/sync/push", axum_post(sync_push))
16104            .with_state(test_app_state(state));
16105        let body = serde_json::json!({
16106            "sender_agent_id": "ai:peer",
16107            "memories": [],
16108            "links": [{
16109                "source_id": src,
16110                "target_id": tgt,
16111                "relation": "related_to",
16112                "created_at": "2024-01-01T00:00:00Z",
16113            }],
16114            "dry_run": true,
16115        });
16116        let resp = app
16117            .oneshot(
16118                axum::http::Request::builder()
16119                    .uri("/api/v1/sync/push")
16120                    .method("POST")
16121                    .header("content-type", "application/json")
16122                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16123                    .unwrap(),
16124            )
16125            .await
16126            .unwrap();
16127        assert_eq!(resp.status(), StatusCode::OK);
16128        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16129            .await
16130            .unwrap();
16131        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16132        assert_eq!(v["links_applied"], 0);
16133        assert_eq!(v["dry_run"], true);
16134    }
16135
16136    // ---- consolidate_memories validation: tier=short clamps title ----
16137
16138    #[tokio::test]
16139    async fn http_consolidate_invalid_title_returns_400() {
16140        let state = test_state();
16141        let id1 = insert_test_memory(&state, "ns-cons", "a").await;
16142        let id2 = insert_test_memory(&state, "ns-cons", "b").await;
16143        let app = Router::new()
16144            .route("/api/v1/consolidate", axum_post(consolidate_memories))
16145            .with_state(test_app_state(state));
16146        let body = serde_json::json!({
16147            "ids": [id1, id2],
16148            "title": "",
16149            "summary": "Summary text",
16150            "namespace": "ns-cons",
16151        });
16152        let resp = app
16153            .oneshot(
16154                axum::http::Request::builder()
16155                    .uri("/api/v1/consolidate")
16156                    .method("POST")
16157                    .header("content-type", "application/json")
16158                    .header("x-agent-id", "ai:tester")
16159                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16160                    .unwrap(),
16161            )
16162            .await
16163            .unwrap();
16164        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16165    }
16166
16167    // ---- bulk_create empty body returns 200 with zero ----
16168
16169    #[tokio::test]
16170    async fn http_bulk_create_zero_body_returns_zero_created() {
16171        let state = test_state();
16172        let app = Router::new()
16173            .route("/api/v1/memories/bulk", axum_post(bulk_create))
16174            .with_state(test_app_state(state));
16175        let body: Vec<serde_json::Value> = Vec::new();
16176        let resp = app
16177            .oneshot(
16178                axum::http::Request::builder()
16179                    .uri("/api/v1/memories/bulk")
16180                    .method("POST")
16181                    .header("content-type", "application/json")
16182                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16183                    .unwrap(),
16184            )
16185            .await
16186            .unwrap();
16187        assert_eq!(resp.status(), StatusCode::OK);
16188        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16189            .await
16190            .unwrap();
16191        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16192        assert_eq!(v["created"], 0);
16193    }
16194
16195    // ---- entity_register: blank canonical_name skips validation ----
16196
16197    #[tokio::test]
16198    async fn http_entity_register_with_x_agent_id_header_succeeds() {
16199        let state = test_state();
16200        let app = Router::new()
16201            .route("/api/v1/entities", axum_post(entity_register))
16202            .with_state(state);
16203        let body = serde_json::json!({
16204            "canonical_name": "Acme Inc",
16205            "namespace": "corp",
16206            "aliases": ["acme", "ACME"],
16207        });
16208        let resp = app
16209            .oneshot(
16210                axum::http::Request::builder()
16211                    .uri("/api/v1/entities")
16212                    .method("POST")
16213                    .header("content-type", "application/json")
16214                    .header("x-agent-id", "ai:tester")
16215                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16216                    .unwrap(),
16217            )
16218            .await
16219            .unwrap();
16220        assert_eq!(resp.status(), StatusCode::CREATED);
16221        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16222            .await
16223            .unwrap();
16224        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16225        assert_eq!(v["created"], true);
16226        assert_eq!(v["canonical_name"], "Acme Inc");
16227    }
16228
16229    // ---- inbox: blank query without header returns BAD_REQUEST? ----
16230
16231    #[tokio::test]
16232    async fn http_get_inbox_without_caller_uses_anonymous_default() {
16233        // No x-agent-id header, no agent_id query param. The handler
16234        // resolves to an anonymous identity and returns OK with an
16235        // empty inbox.
16236        let state = test_state();
16237        let app = Router::new()
16238            .route("/api/v1/inbox", axum_get(get_inbox))
16239            .with_state(test_app_state(state));
16240        let resp = app
16241            .oneshot(
16242                axum::http::Request::builder()
16243                    .uri("/api/v1/inbox")
16244                    .body(Body::empty())
16245                    .unwrap(),
16246            )
16247            .await
16248            .unwrap();
16249        assert_eq!(resp.status(), StatusCode::OK);
16250    }
16251
16252    // ---- approve_pending invalid x-agent-id ----
16253
16254    #[tokio::test]
16255    async fn http_approve_pending_with_bad_header_agent_id_returns_400() {
16256        let state = test_state();
16257        let app = Router::new()
16258            .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
16259            .with_state(test_app_state(state));
16260        let id = "abcdef0123456789abcdef0123456789";
16261        let resp = app
16262            .oneshot(
16263                axum::http::Request::builder()
16264                    .uri(format!("/api/v1/pending/{id}/approve"))
16265                    .method("POST")
16266                    .header("x-agent-id", "bad agent id")
16267                    .body(Body::empty())
16268                    .unwrap(),
16269            )
16270            .await
16271            .unwrap();
16272        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16273    }
16274
16275    // ---- reject_pending invalid x-agent-id ----
16276
16277    #[tokio::test]
16278    async fn http_reject_pending_with_bad_header_agent_id_returns_400() {
16279        let state = test_state();
16280        let app = Router::new()
16281            .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
16282            .with_state(test_app_state(state));
16283        let id = "abcdef0123456789abcdef0123456789";
16284        let resp = app
16285            .oneshot(
16286                axum::http::Request::builder()
16287                    .uri(format!("/api/v1/pending/{id}/reject"))
16288                    .method("POST")
16289                    .header("x-agent-id", "bad agent id")
16290                    .body(Body::empty())
16291                    .unwrap(),
16292            )
16293            .await
16294            .unwrap();
16295        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16296    }
16297
16298    // ---- create_memory invalid x-agent-id header ----
16299
16300    #[tokio::test]
16301    async fn http_create_memory_invalid_x_agent_id_header_returns_400() {
16302        let state = test_state();
16303        let app = Router::new()
16304            .route("/api/v1/memories", axum_post(create_memory))
16305            .with_state(test_app_state(state));
16306        let body = serde_json::json!({
16307            "tier": "long",
16308            "namespace": "test",
16309            "title": "t",
16310            "content": "c",
16311            "tags": [],
16312            "priority": 5,
16313            "confidence": 1.0,
16314            "source": "api",
16315            "metadata": {}
16316        });
16317        let resp = app
16318            .oneshot(
16319                axum::http::Request::builder()
16320                    .uri("/api/v1/memories")
16321                    .method("POST")
16322                    .header("content-type", "application/json")
16323                    .header("x-agent-id", "bad agent id")
16324                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16325                    .unwrap(),
16326            )
16327            .await
16328            .unwrap();
16329        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16330        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16331            .await
16332            .unwrap();
16333        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16334        assert!(v["error"].as_str().unwrap().contains("agent_id"));
16335    }
16336
16337    // ---- create_memory rejects invalid scope ----
16338
16339    #[tokio::test]
16340    async fn http_create_memory_invalid_scope_returns_400() {
16341        let state = test_state();
16342        let app = Router::new()
16343            .route("/api/v1/memories", axum_post(create_memory))
16344            .with_state(test_app_state(state));
16345        // scope must be one of the recognised tokens; gibberish fails
16346        // validate_scope.
16347        let body = serde_json::json!({
16348            "tier": "long",
16349            "namespace": "test",
16350            "title": "t",
16351            "content": "c",
16352            "tags": [],
16353            "priority": 5,
16354            "confidence": 1.0,
16355            "source": "api",
16356            "metadata": {},
16357            "scope": "not-a-valid-scope-token"
16358        });
16359        let resp = app
16360            .oneshot(
16361                axum::http::Request::builder()
16362                    .uri("/api/v1/memories")
16363                    .method("POST")
16364                    .header("content-type", "application/json")
16365                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16366                    .unwrap(),
16367            )
16368            .await
16369            .unwrap();
16370        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16371    }
16372
16373    // ---- list_memories invalid agent_id filter ----
16374
16375    #[tokio::test]
16376    async fn http_list_memories_invalid_agent_id_filter_returns_400() {
16377        let state = test_state();
16378        let app = Router::new()
16379            .route("/api/v1/memories", axum_get(list_memories))
16380            .with_state(state);
16381        let resp = app
16382            .oneshot(
16383                axum::http::Request::builder()
16384                    .uri("/api/v1/memories?agent_id=bad%20id")
16385                    .body(Body::empty())
16386                    .unwrap(),
16387            )
16388            .await
16389            .unwrap();
16390        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16391    }
16392
16393    // ---- check_duplicate with no embedder + namespace=blank-trimmed ----
16394
16395    #[tokio::test]
16396    async fn http_check_duplicate_blank_namespace_treated_as_none() {
16397        // namespace is " " — trimmed to empty, treated as None — handler
16398        // proceeds and 503s on missing embedder rather than 400.
16399        let state = test_state();
16400        let app = Router::new()
16401            .route("/api/v1/check_duplicate", axum_post(check_duplicate))
16402            .with_state(test_app_state(state));
16403        let body = serde_json::json!({"title": "t", "content": "c", "namespace": "   "});
16404        let resp = app
16405            .oneshot(
16406                axum::http::Request::builder()
16407                    .uri("/api/v1/check_duplicate")
16408                    .method("POST")
16409                    .header("content-type", "application/json")
16410                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16411                    .unwrap(),
16412            )
16413            .await
16414            .unwrap();
16415        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
16416    }
16417
16418    // ---- archive_by_ids: missing reason field defaults to "archive" ----
16419    // (Validates default-string path; existing test covers the default
16420    // path implicitly but we add an explicit body shape.)
16421
16422    #[tokio::test]
16423    async fn http_archive_by_ids_with_no_reason_defaults_to_archive() {
16424        let state = test_state();
16425        let id = insert_test_memory(&state, "ns-arch-default", "row").await;
16426        let app = Router::new()
16427            .route("/api/v1/archive", axum_post(archive_by_ids))
16428            .with_state(test_app_state(state));
16429        let body = serde_json::json!({"ids": [id]});
16430        let resp = app
16431            .oneshot(
16432                axum::http::Request::builder()
16433                    .uri("/api/v1/archive")
16434                    .method("POST")
16435                    .header("content-type", "application/json")
16436                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16437                    .unwrap(),
16438            )
16439            .await
16440            .unwrap();
16441        assert_eq!(resp.status(), StatusCode::OK);
16442        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16443            .await
16444            .unwrap();
16445        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16446        assert_eq!(v["reason"], "archive");
16447    }
16448
16449    // ---- Governance Pending paths for create/delete/promote ----
16450    //
16451    // These set up an `approve` write/delete/promote policy on a namespace
16452    // standard so the corresponding handler hits the
16453    // `GovernanceDecision::Pending` arm — exercising the queue+202 response
16454    // path that the federation-disabled tests cannot otherwise reach.
16455
16456    /// Seed a `_namespace_standard` memory with the supplied governance
16457    /// policy and wire `namespace_meta` to it. Returns nothing — caller
16458    /// just queries the namespace afterward.
16459    async fn seed_governance_policy(state: &Db, ns: &str, policy: serde_json::Value) {
16460        let lock = state.lock().await;
16461        let now = Utc::now().to_rfc3339();
16462        let standard = Memory {
16463            id: Uuid::new_v4().to_string(),
16464            tier: Tier::Long,
16465            namespace: ns.into(),
16466            title: format!("_standard:{ns}"),
16467            content: format!("standard for {ns}"),
16468            tags: vec!["_namespace_standard".to_string()],
16469            priority: 5,
16470            confidence: 1.0,
16471            source: "test".into(),
16472            access_count: 0,
16473            created_at: now.clone(),
16474            updated_at: now,
16475            last_accessed_at: None,
16476            expires_at: None,
16477            metadata: serde_json::json!({
16478                "agent_id": "ai:owner",
16479                "governance": policy,
16480            }),
16481        };
16482        let standard_id = db::insert(&lock.0, &standard).unwrap();
16483        db::set_namespace_standard(&lock.0, ns, &standard_id, None).unwrap();
16484    }
16485
16486    #[tokio::test]
16487    async fn http_create_memory_governance_pending_returns_202() {
16488        let state = test_state();
16489        seed_governance_policy(
16490            &state,
16491            "gov-create",
16492            serde_json::json!({
16493                "write": "approve",
16494                "delete": "owner",
16495                "promote": "any",
16496                "approver": "human",
16497            }),
16498        )
16499        .await;
16500        let app = Router::new()
16501            .route("/api/v1/memories", axum_post(create_memory))
16502            .with_state(test_app_state(state));
16503        let body = serde_json::json!({
16504            "tier": "long",
16505            "namespace": "gov-create",
16506            "title": "queued",
16507            "content": "should be queued, not stored",
16508            "tags": [],
16509            "priority": 5,
16510            "confidence": 1.0,
16511            "source": "api",
16512            "metadata": {},
16513        });
16514        let resp = app
16515            .oneshot(
16516                axum::http::Request::builder()
16517                    .uri("/api/v1/memories")
16518                    .method("POST")
16519                    .header("content-type", "application/json")
16520                    .header("x-agent-id", "ai:caller")
16521                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16522                    .unwrap(),
16523            )
16524            .await
16525            .unwrap();
16526        assert_eq!(resp.status(), StatusCode::ACCEPTED);
16527        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16528            .await
16529            .unwrap();
16530        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16531        assert_eq!(v["status"], "pending");
16532        assert_eq!(v["action"], "store");
16533        assert!(v["pending_id"].is_string());
16534    }
16535
16536    #[tokio::test]
16537    async fn http_create_memory_governance_deny_returns_403() {
16538        // write: registered → unregistered caller is denied without queueing.
16539        let state = test_state();
16540        seed_governance_policy(
16541            &state,
16542            "gov-deny",
16543            serde_json::json!({"write": "registered", "approver": "human"}),
16544        )
16545        .await;
16546        let app = Router::new()
16547            .route("/api/v1/memories", axum_post(create_memory))
16548            .with_state(test_app_state(state));
16549        let body = serde_json::json!({
16550            "tier": "long",
16551            "namespace": "gov-deny",
16552            "title": "rejected",
16553            "content": "rejected content",
16554            "tags": [],
16555            "priority": 5,
16556            "confidence": 1.0,
16557            "source": "api",
16558            "metadata": {},
16559        });
16560        let resp = app
16561            .oneshot(
16562                axum::http::Request::builder()
16563                    .uri("/api/v1/memories")
16564                    .method("POST")
16565                    .header("content-type", "application/json")
16566                    .header("x-agent-id", "ai:unregistered")
16567                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16568                    .unwrap(),
16569            )
16570            .await
16571            .unwrap();
16572        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
16573        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16574            .await
16575            .unwrap();
16576        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16577        assert!(v["error"].as_str().unwrap().contains("governance"));
16578    }
16579
16580    #[tokio::test]
16581    async fn http_delete_memory_governance_pending_returns_202() {
16582        let state = test_state();
16583        seed_governance_policy(
16584            &state,
16585            "gov-delete",
16586            serde_json::json!({
16587                "write": "any",
16588                "delete": "approve",
16589                "promote": "any",
16590                "approver": "human",
16591            }),
16592        )
16593        .await;
16594        let id = insert_test_memory(&state, "gov-delete", "to-delete").await;
16595        let app = Router::new()
16596            .route(
16597                "/api/v1/memories/{id}",
16598                axum::routing::delete(delete_memory),
16599            )
16600            .with_state(test_app_state(state));
16601        let resp = app
16602            .oneshot(
16603                axum::http::Request::builder()
16604                    .uri(format!("/api/v1/memories/{id}"))
16605                    .method("DELETE")
16606                    .header("x-agent-id", "ai:caller")
16607                    .body(Body::empty())
16608                    .unwrap(),
16609            )
16610            .await
16611            .unwrap();
16612        assert_eq!(resp.status(), StatusCode::ACCEPTED);
16613        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16614            .await
16615            .unwrap();
16616        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16617        assert_eq!(v["status"], "pending");
16618        assert_eq!(v["action"], "delete");
16619        assert_eq!(v["memory_id"], id);
16620    }
16621
16622    #[tokio::test]
16623    async fn http_delete_memory_governance_deny_returns_403() {
16624        let state = test_state();
16625        seed_governance_policy(
16626            &state,
16627            "gov-delete-deny",
16628            serde_json::json!({"write": "any", "delete": "owner", "approver": "human"}),
16629        )
16630        .await;
16631        // The seeded memory's owner is "ai:owner" (set by insert_test_memory's
16632        // default empty metadata, but here we want a different owner so the
16633        // current caller fails the owner check). insert_test_memory writes
16634        // metadata={} so the row has no agent_id → caller "ai:other" cannot
16635        // pass the owner check (memory_owner=None means deny).
16636        let id = insert_test_memory(&state, "gov-delete-deny", "row").await;
16637        let app = Router::new()
16638            .route(
16639                "/api/v1/memories/{id}",
16640                axum::routing::delete(delete_memory),
16641            )
16642            .with_state(test_app_state(state));
16643        let resp = app
16644            .oneshot(
16645                axum::http::Request::builder()
16646                    .uri(format!("/api/v1/memories/{id}"))
16647                    .method("DELETE")
16648                    .header("x-agent-id", "ai:other")
16649                    .body(Body::empty())
16650                    .unwrap(),
16651            )
16652            .await
16653            .unwrap();
16654        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
16655    }
16656
16657    #[tokio::test]
16658    async fn http_promote_memory_governance_pending_returns_202() {
16659        let state = test_state();
16660        seed_governance_policy(
16661            &state,
16662            "gov-promote",
16663            serde_json::json!({
16664                "write": "any",
16665                "delete": "any",
16666                "promote": "approve",
16667                "approver": "human",
16668            }),
16669        )
16670        .await;
16671        let id = insert_test_memory(&state, "gov-promote", "to-promote").await;
16672        let app = Router::new()
16673            .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
16674            .with_state(test_app_state(state));
16675        let resp = app
16676            .oneshot(
16677                axum::http::Request::builder()
16678                    .uri(format!("/api/v1/memories/{id}/promote"))
16679                    .method("POST")
16680                    .header("x-agent-id", "ai:caller")
16681                    .body(Body::empty())
16682                    .unwrap(),
16683            )
16684            .await
16685            .unwrap();
16686        assert_eq!(resp.status(), StatusCode::ACCEPTED);
16687        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16688            .await
16689            .unwrap();
16690        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16691        assert_eq!(v["status"], "pending");
16692        assert_eq!(v["action"], "promote");
16693        assert_eq!(v["memory_id"], id);
16694    }
16695
16696    // ---- create_memory contradiction-check happy path with metadata scope ----
16697
16698    #[tokio::test]
16699    async fn http_create_memory_with_top_level_scope_succeeds() {
16700        let state = test_state();
16701        let app = Router::new()
16702            .route("/api/v1/memories", axum_post(create_memory))
16703            .with_state(test_app_state(state));
16704        let body = serde_json::json!({
16705            "tier": "long",
16706            "namespace": "scoped",
16707            "title": "with scope",
16708            "content": "scoped content",
16709            "tags": [],
16710            "priority": 5,
16711            "confidence": 1.0,
16712            "source": "api",
16713            "metadata": {},
16714            "scope": "private"
16715        });
16716        let resp = app
16717            .oneshot(
16718                axum::http::Request::builder()
16719                    .uri("/api/v1/memories")
16720                    .method("POST")
16721                    .header("content-type", "application/json")
16722                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16723                    .unwrap(),
16724            )
16725            .await
16726            .unwrap();
16727        assert_eq!(resp.status(), StatusCode::CREATED);
16728    }
16729
16730    // ---- create_memory clamps priority/confidence ----
16731
16732    #[tokio::test]
16733    async fn http_create_memory_clamps_extreme_priority_to_range() {
16734        let state = test_state();
16735        let app = Router::new()
16736            .route("/api/v1/memories", axum_post(create_memory))
16737            .with_state(test_app_state(state.clone()));
16738        // priority=15 is an attempted overflow but validate_create
16739        // rejects out-of-range so we use 10 (max) which clamps to 10.
16740        let body = serde_json::json!({
16741            "tier": "long",
16742            "namespace": "clamp",
16743            "title": "clamp",
16744            "content": "c",
16745            "tags": [],
16746            "priority": 10,
16747            "confidence": 1.0,
16748            "source": "api",
16749            "metadata": {},
16750        });
16751        let resp = app
16752            .oneshot(
16753                axum::http::Request::builder()
16754                    .uri("/api/v1/memories")
16755                    .method("POST")
16756                    .header("content-type", "application/json")
16757                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16758                    .unwrap(),
16759            )
16760            .await
16761            .unwrap();
16762        assert_eq!(resp.status(), StatusCode::CREATED);
16763        // Verify priority preserved at the max.
16764        let lock = state.lock().await;
16765        let rows = db::list(
16766            &lock.0,
16767            Some("clamp"),
16768            None,
16769            10,
16770            0,
16771            None,
16772            None,
16773            None,
16774            None,
16775            None,
16776        )
16777        .unwrap();
16778        assert_eq!(rows[0].priority, 10);
16779    }
16780
16781    // ---- update_memory invalid update body validation ----
16782
16783    #[tokio::test]
16784    async fn http_update_memory_with_oversized_title_returns_400() {
16785        let state = test_state();
16786        let id = insert_test_memory(&state, "ns-bigtitle", "old").await;
16787        let app = Router::new()
16788            .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
16789            .with_state(test_app_state(state));
16790        // title length cap is enforced via validate_update → validate_title.
16791        let big_title = "T".repeat(10_000);
16792        let body = serde_json::json!({"title": big_title});
16793        let resp = app
16794            .oneshot(
16795                axum::http::Request::builder()
16796                    .uri(format!("/api/v1/memories/{id}"))
16797                    .method("PUT")
16798                    .header("content-type", "application/json")
16799                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16800                    .unwrap(),
16801            )
16802            .await
16803            .unwrap();
16804        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16805    }
16806
16807    // ---- delete_memory invalid id length too long via header agent ----
16808
16809    #[tokio::test]
16810    async fn http_purge_archive_no_query_returns_purged_zero_for_empty_archive() {
16811        let state = test_state();
16812        let app = Router::new()
16813            .route("/api/v1/archive", axum::routing::delete(purge_archive))
16814            .with_state(state);
16815        let resp = app
16816            .oneshot(
16817                axum::http::Request::builder()
16818                    .uri("/api/v1/archive")
16819                    .method("DELETE")
16820                    .body(Body::empty())
16821                    .unwrap(),
16822            )
16823            .await
16824            .unwrap();
16825        assert_eq!(resp.status(), StatusCode::OK);
16826        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16827            .await
16828            .unwrap();
16829        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16830        assert_eq!(v["purged"], 0);
16831    }
16832
16833    // ---- detect_contradictions: invalid topic only (no namespace) accepted ----
16834
16835    #[tokio::test]
16836    async fn http_contradictions_topic_only_returns_ok_empty() {
16837        let state = test_state();
16838        let app = Router::new()
16839            .route("/api/v1/contradictions", axum_get(detect_contradictions))
16840            .with_state(state);
16841        let resp = app
16842            .oneshot(
16843                axum::http::Request::builder()
16844                    .uri("/api/v1/contradictions?topic=missing-topic")
16845                    .body(Body::empty())
16846                    .unwrap(),
16847            )
16848            .await
16849            .unwrap();
16850        assert_eq!(resp.status(), StatusCode::OK);
16851    }
16852
16853    // ---- entity_register collision (kind != entity) ----
16854
16855    #[tokio::test]
16856    async fn http_entity_register_aliases_with_blanks_filtered() {
16857        let state = test_state();
16858        let app = Router::new()
16859            .route("/api/v1/entities", axum_post(entity_register))
16860            .with_state(state);
16861        let body = serde_json::json!({
16862            "canonical_name": "Globex",
16863            "namespace": "corp2",
16864            "aliases": ["", "globex", "  ", "GLOBEX"],
16865        });
16866        let resp = app
16867            .oneshot(
16868                axum::http::Request::builder()
16869                    .uri("/api/v1/entities")
16870                    .method("POST")
16871                    .header("content-type", "application/json")
16872                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16873                    .unwrap(),
16874            )
16875            .await
16876            .unwrap();
16877        assert_eq!(resp.status(), StatusCode::CREATED);
16878    }
16879
16880    // ---- subscribe with explicit URL form ----
16881
16882    #[tokio::test]
16883    async fn http_subscribe_with_explicit_url_succeeds() {
16884        let state = test_state();
16885        let app = Router::new()
16886            .route("/api/v1/subscribe", axum_post(subscribe))
16887            .with_state(test_app_state(state));
16888        let body = serde_json::json!({
16889            "agent_id": "ai:webhook-user",
16890            "url": "http://localhost:9999/webhook",
16891            "events": "store",
16892            "secret": "shhh",
16893            "namespace_filter": "team",
16894        });
16895        let resp = app
16896            .oneshot(
16897                axum::http::Request::builder()
16898                    .uri("/api/v1/subscribe")
16899                    .method("POST")
16900                    .header("content-type", "application/json")
16901                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
16902                    .unwrap(),
16903            )
16904            .await
16905            .unwrap();
16906        assert_eq!(resp.status(), StatusCode::CREATED);
16907        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16908            .await
16909            .unwrap();
16910        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16911        assert_eq!(v["url"], "http://localhost:9999/webhook");
16912        assert_eq!(v["events"], "store");
16913    }
16914
16915    // ---- unsubscribe by id directly through MCP path ----
16916
16917    #[tokio::test]
16918    async fn http_unsubscribe_by_unknown_id_returns_ok_unchanged() {
16919        let state = test_state();
16920        let app = Router::new()
16921            .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
16922            .with_state(test_app_state(state));
16923        // id=<bogus> path delegates to handle_unsubscribe which returns
16924        // Ok with `removed: false`.
16925        let resp = app
16926            .oneshot(
16927                axum::http::Request::builder()
16928                    .uri("/api/v1/subscribe?id=does-not-exist")
16929                    .method("DELETE")
16930                    .body(Body::empty())
16931                    .unwrap(),
16932            )
16933            .await
16934            .unwrap();
16935        // Unknown id maps to Ok inside handle_unsubscribe with removed=false.
16936        // The handler always responds 200 from the Ok arm.
16937        assert!(
16938            resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST,
16939            "got {}",
16940            resp.status()
16941        );
16942    }
16943}