// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! Inline-test scaffold extracted from `src/handlers/mod.rs` as part
//! of the issue #650 file-architecture cleanup. Attached to the
//! parent module via `#[cfg(test)] #[path = "tests.rs"] mod tests;`.
//!
//! `use super::*` pulls in the re-exported `handlers::*` surface
//! (every handler + helper from the per-domain sub-modules). The
//! explicit imports below replace the test-only `#[cfg(test)] use`
//! lines that previously sat at the top of `mod.rs`.

// #873 — large per-test scaffolding plus a few seed helpers exceed the
// 250-line budget. Test scaffolds get the same allowance as the
// production files they pin (no need to chunk a test setup helper into
// per-step closures); tracked alongside the parent #650 follow-up.
#![allow(clippy::too_many_lines)]

use super::*;

use crate::db;
use crate::models::Memory;

// Axum extractors / types used by individual inline tests.
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::Utc;
use serde_json::json;

// Test-only imports — previously declared `#[cfg(test)]` in the
// pre-split mod.rs. Re-declared here at this file's top so the
// inline tests below resolve identifiers identically.
use super::http::maybe_auto_tag;
use crate::config::ResolvedTtl;
use crate::embeddings::Embedder;
use crate::models::Tier;
use crate::profile::Family;
use chrono::Duration;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use uuid::Uuid;

/// v0.7.0 #238/#239 (security hardening): the lib-tier in-process
/// federation tests pre-date the `x-peer-id` attestation step and
/// don't carry the header. They model legacy peers that the
/// brief's `AI_MEMORY_FED_TRUST_BODY_AGENT_ID` /
/// `AI_MEMORY_FED_SYNC_TRUST_PEER` env bypasses are designed for
/// exactly. Set both at module-load time so every lib test sees
/// the legacy posture. Integration tests at `tests/g_issue_238_*`
/// and `tests/g_issue_239_*` exercise the default-enforce posture
/// in a separate test binary (independent process env), so this
/// flip does not contaminate the new regression tests.
static SECURITY_BYPASS_INIT: std::sync::Once = std::sync::Once::new();
fn install_security_bypass_for_legacy_tests() {
    SECURITY_BYPASS_INIT.call_once(|| {
        // SAFETY: serial-by-module test initialisation; the env
        // var is read by handler code only (no concurrent writer).
        unsafe {
            std::env::set_var(
                crate::federation::peer_attestation::TRUST_BODY_AGENT_ID_ENV,
                "1",
            );
            std::env::set_var(
                crate::federation::peer_attestation::SYNC_TRUST_PEER_ENV,
                "1",
            );
        }
        // #1570 — the lib-tier admin-gate tests model an AUTHENTICATED
        // deployment (the pre-#1570 implicit posture). Mark request
        // authn configured so the admin-role gate honors the header
        // role claims these legacy tests assert. The #1570 secure
        // default (bare header on an unauthenticated deployment →
        // 403) is pinned by `tests/admin_header_trust_1570.rs` in its
        // own process, where this marker is never set.
        crate::handlers::admin_role::mark_request_authn_configured(true);
    });
}

fn test_state() -> Db {
    install_security_bypass_for_legacy_tests();
    let conn = db::open(std::path::Path::new(":memory:")).unwrap();
    let path = std::path::PathBuf::from(":memory:");
    Arc::new(Mutex::new((conn, path, ResolvedTtl::default(), true)))
}

/// S5-C1 fix campaign (2026-05-13): tests that hit
/// `approve_pending` / `reject_pending` MUST acquire this mutex and
/// install a server-wide HMAC secret via
/// [`crate::config::set_active_hooks_hmac_secret`] before signing.
/// The process-wide secret is global state; serialising prevents
/// cross-test interleaving from flipping the secret out from under
/// an in-flight request.
pub(super) static APPROVE_HMAC_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// #1789 — these `http_sync_push_*` / `http_sync_since_*` handler tests
/// exercise DOWNSTREAM business logic (oversize-batch 400, applies-clock,
/// skips-invalid-id, dry-run no-apply, …), NOT the peer-enrollment gate.
/// They send a sync request with NO `X-Peer-Id` header + no signature
/// (the `(None, None)` unenrolled-peer arm). Commit `6672312b` (#1789)
/// flipped `AI_MEMORY_FED_REQUIRE_PEER_ENROLLMENT` to default-ON, so that
/// arm now `401 peer_not_enrolled`s BEFORE the handler reaches the logic
/// these tests assert. Opt back to permissive via the wired escape hatch
/// `AI_MEMORY_FED_ALLOW_UNENROLLED_PEERS=1` so the secure default stays
/// semantically ON while the test exercises the permissive path.
///
/// RAII: sets on `new`, restores the prior value on `Drop`. It acquires
/// the SAME crate-level lock ([`crate::handlers::fed_env_test_lock`]) the
/// strict enrollment tests in `federation_signing_check` hold, so the two
/// test sets are mutually serialised and a parallel Check-matrix run can
/// never leak the enrollment env across them.
struct PermissiveFedEnv {
    _guard: std::sync::MutexGuard<'static, ()>,
    prev: Option<String>,
}

impl PermissiveFedEnv {
    fn new() -> Self {
        let guard = crate::handlers::fed_env_test_lock();
        let key = "AI_MEMORY_FED_ALLOW_UNENROLLED_PEERS";
        let prev = std::env::var(key).ok();
        // SAFETY: env mutation under the shared test-scoped lock; the var
        // is read by handler code only (no concurrent writer while held).
        unsafe {
            std::env::set_var(key, "1");
        }
        Self {
            _guard: guard,
            prev,
        }
    }
}

impl Drop for PermissiveFedEnv {
    fn drop(&mut self) {
        let key = "AI_MEMORY_FED_ALLOW_UNENROLLED_PEERS";
        // SAFETY: still holding the shared lock for the guard's lifetime.
        unsafe {
            match &self.prev {
                Some(v) => std::env::set_var(key, v),
                None => std::env::remove_var(key),
            }
        }
    }
}

/// S5-C1 fix campaign (2026-05-13): synthesise a K7-style
/// `(timestamp, signature)` pair for an inbound approve/reject body.
/// Mirrors the reference helper in `tests/k10_approval_http.rs::sign`
/// but uses the in-crate `pub(crate)` helpers so we don't redeclare
/// the HMAC primitive in two places.
pub(super) fn sign_approve_body(
    secret: &str,
    method: &str,
    pending_id: &str,
    body: &[u8],
) -> (String, String) {
    let ts = chrono::Utc::now().timestamp().to_string();
    let body_str = std::str::from_utf8(body).unwrap_or("");
    let canonical = format!("{ts}.{method}.{pending_id}.{body_str}");
    let key_hash = crate::subscriptions::sha256_hex(secret);
    let sig = crate::subscriptions::hmac_sha256_hex(&key_hash, &canonical);
    (ts, format!("sha256={sig}"))
}

#[tokio::test]
async fn health_returns_ok() {
    let state = test_state();
    let lock = state.lock().await;
    let ok = db::health_check(&lock.0).unwrap_or(false);
    assert!(ok);
}

#[tokio::test]
async fn store_and_retrieve_via_state() {
    let state = test_state();
    let lock = state.lock().await;
    let now = Utc::now();
    let mem = Memory {
        id: Uuid::new_v4().to_string(),
        tier: Tier::Long,
        namespace: "test".into(),
        title: "Handler test".into(),
        content: "Testing handlers.".into(),
        tags: vec!["test".into()],
        priority: 7,
        confidence: 1.0,
        source: "test".into(),
        access_count: 0,
        created_at: now.to_rfc3339(),
        updated_at: now.to_rfc3339(),
        last_accessed_at: None,
        expires_at: None,
        metadata: serde_json::json!({}),
        reflection_depth: 0,
        memory_kind: crate::models::MemoryKind::Observation,
        entity_id: None,
        persona_version: None,
        citations: Vec::new(),
        source_uri: None,
        source_span: None,
        confidence_source: crate::models::ConfidenceSource::CallerProvided,
        confidence_signals: None,
        confidence_decayed_at: None,
        version: 1,
        lifecycle_state: crate::models::LifecycleState::Open,
    };
    let id = db::insert(&lock.0, &mem).unwrap();
    let got = db::get(&lock.0, &id).unwrap().unwrap();
    assert_eq!(got.title, "Handler test");
}

#[tokio::test]
async fn recall_via_state() {
    let state = test_state();
    let lock = state.lock().await;
    let now = Utc::now();
    let mem = Memory {
        id: Uuid::new_v4().to_string(),
        tier: Tier::Long,
        namespace: "test".into(),
        title: "Recall handler test".into(),
        content: "Content for recall.".into(),
        tags: vec![],
        priority: 8,
        confidence: 1.0,
        source: "test".into(),
        access_count: 0,
        created_at: now.to_rfc3339(),
        updated_at: now.to_rfc3339(),
        last_accessed_at: None,
        expires_at: None,
        metadata: serde_json::json!({}),
        reflection_depth: 0,
        memory_kind: crate::models::MemoryKind::Observation,
        entity_id: None,
        persona_version: None,
        citations: Vec::new(),
        source_uri: None,
        source_span: None,
        confidence_source: crate::models::ConfidenceSource::CallerProvided,
        confidence_signals: None,
        confidence_decayed_at: None,
        version: 1,
        lifecycle_state: crate::models::LifecycleState::Open,
    };
    db::insert(&lock.0, &mem).unwrap();
    let (results, _outcome) = db::recall(
        &lock.0,
        "recall handler",
        Some("test"),
        10,
        None,
        None,
        None,
        crate::models::SHORT_TTL_EXTEND_SECS,
        crate::models::MID_TTL_EXTEND_SECS,
        None,
        None,
        false,
        None,
        None, // #1720 caller
    )
    .unwrap();
    assert!(!results.is_empty());
    assert!(results[0].1 > 0.0); // has score
}

#[tokio::test]
async fn stats_via_state() {
    let state = test_state();
    let lock = state.lock().await;
    let path = std::path::Path::new(":memory:");
    let s = db::stats(&lock.0, path).unwrap();
    assert_eq!(s.total, 0);
}

#[tokio::test]
async fn bulk_size_limit() {
    assert_eq!(MAX_BULK_SIZE, 1000);
}

#[tokio::test]
async fn list_empty_namespace() {
    let state = test_state();
    let lock = state.lock().await;
    let results = db::list(
        &lock.0,
        Some("nonexistent"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert!(results.is_empty());
}

#[tokio::test]
async fn create_and_update_with_metadata() {
    let state = test_state();
    let lock = state.lock().await;
    let now = Utc::now();

    // Create with metadata
    let mem = Memory {
        id: Uuid::new_v4().to_string(),
        tier: Tier::Long,
        namespace: "test".into(),
        title: "HTTP metadata test".into(),
        content: "Testing metadata through handler layer.".into(),
        tags: vec![],
        priority: 5,
        confidence: 1.0,
        source: "api".into(),
        access_count: 0,
        created_at: now.to_rfc3339(),
        updated_at: now.to_rfc3339(),
        last_accessed_at: None,
        expires_at: None,
        metadata: serde_json::json!({"http_test": true, "version": 1}),
        reflection_depth: 0,
        memory_kind: crate::models::MemoryKind::Observation,
        entity_id: None,
        persona_version: None,
        citations: Vec::new(),
        source_uri: None,
        source_span: None,
        confidence_source: crate::models::ConfidenceSource::CallerProvided,
        confidence_signals: None,
        confidence_decayed_at: None,
        version: 1,
        lifecycle_state: crate::models::LifecycleState::Open,
    };
    let id = db::insert(&lock.0, &mem).unwrap();

    // Verify metadata persisted
    let got = db::get(&lock.0, &id).unwrap().unwrap();
    assert_eq!(got.metadata["http_test"], true);
    assert_eq!(got.metadata["version"], 1);

    // Update metadata via db::update (same path as update_memory handler)
    let new_meta = serde_json::json!({"http_test": true, "version": 2, "updated_by": "handler"});
    let (found, _) = db::update(
        &lock.0,
        &id,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        Some(&new_meta),
    )
    .unwrap();
    assert!(found);

    // Verify updated metadata
    let got = db::get(&lock.0, &id).unwrap().unwrap();
    assert_eq!(got.metadata["version"], 2);
    assert_eq!(got.metadata["updated_by"], "handler");
}

// --- AppState wiring tests (issue #219) ---

use axum::{Router, body::Body, routing::get as axum_get, routing::post as axum_post};
use tower::ServiceExt as _;

fn test_app_state(db: Db) -> AppState {
    // v0.7.0 Wave-3 — test helper. Test fixtures use `:memory:`
    // SQLite for the legacy `db` field (no on-disk path) so the
    // trait-routed `store` field is set to a separate SqliteStore
    // opened against a fresh tempfile. Tests that exercise
    // trait-routed code paths use the dedicated `tests/`
    // harnesses (notably `serve_postgres_smoke.rs`); the unit
    // tests in this module exercise the legacy direct-rusqlite
    // path and never read `app.store`, so the disjoint backing
    // file is harmless.
    AppState {
        db,
        embedder: Arc::new(None),
        vector_index: Arc::new(Mutex::new(None)),
        federation: Arc::new(None),
        tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
        scoring: Arc::new(crate::config::ResolvedScoring::default()),
        profile: Arc::new(crate::profile::Profile::core()),
        mcp_config: Arc::new(None),
        active_keypair: Arc::new(None),
        family_embeddings: Arc::new(RwLock::new(Some(Vec::new()))),
        storage_backend: StorageBackend::Sqlite,
        #[cfg(feature = "sal")]
        store: test_sqlite_store_handle(),
        llm: Arc::new(None),
        auto_tag_model: Arc::new(None),
        llm_call_timeout: std::time::Duration::from_secs(
            crate::config::DEFAULT_LLM_CALL_TIMEOUT_SECS,
        ),
        replay_cache: Arc::new(crate::identity::replay::ReplayCache::new()),
        verify_require_nonce: false,
        federation_nonce_cache: Arc::new(crate::identity::replay::FederationNonceCache::new()),
        autonomous_hooks: false,
        recall_scope: Arc::new(None),
        // v0.7.0 Policy-Engine Item 3 — tests don't spawn the
        // drainer; the queue is None and the storage hook is
        // intentionally absent in test_app_state scaffolds.
        deferred_audit_queue: Arc::new(None),
        // v0.7.0 #974 (lib-test rewrite, 2026-05-20) — seed the
        // `"*"` wildcard admin sentinel so legacy lib tests in this
        // module (authored before the v0.7.0 SHIP-cluster admin-role
        // gates landed) pass through `handlers::admin_role::
        // is_admin_caller` even though they build requests with
        // `Body::empty()` + no `X-Agent-Id` header. Production
        // `AdminConfig::validated_agent_ids` rejects `"*"` via
        // `validate_agent_id`, so this sentinel CANNOT leak into a
        // production deployment. Integration tests in `tests/*.rs`
        // use the closed empty allowlist (`Vec::new()`) for
        // security-gate regression coverage; the
        // `tests/*_admin_gate_*.rs` files pin both postures.
        admin_agent_ids: Arc::new(vec!["*".to_string()]),
        rule_cache: std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new()),
        resolved_models: std::sync::Arc::new(crate::config::ResolvedModels::default()),
        runtime: crate::runtime_context::RuntimeContext::global_arc(),
        max_page_size: crate::handlers::MAX_BULK_SIZE,
    }
}

/// v0.7.0 SHIP cluster (#936, 2026-05-20) — variant of
/// [`test_app_state`] that admits `agent_id` as an
/// `[admin].agent_ids` allowlist member. Used by the three
/// `http_purge_archive_*` happy-path tests which pre-#936 ran
/// against the owner-blind purge surface and need the admin
/// bypass to keep exercising the cross-tenant wipe behaviour they
/// were originally written to cover.
#[cfg(test)]
fn test_app_state_with_admin(db: Db, agent_id: &str) -> AppState {
    let mut state = test_app_state(db);
    state.admin_agent_ids = Arc::new(vec![agent_id.to_string()]);
    state
}

/// v0.7.0 Wave-3 — test-only `Arc<dyn MemoryStore>` that wraps a
/// freshly-opened tempfile-backed SQLite database. The unit tests
/// in this module never call into `app.store`, so the disjoint
/// backing file is harmless — but a populated handle is required
/// to satisfy the `AppState` field shape.
#[cfg(feature = "sal")]
fn test_sqlite_store_handle() -> Arc<dyn crate::store::MemoryStore> {
    let tmp = tempfile::NamedTempFile::new().expect("tempfile for test SqliteStore");
    // Keep the tempfile alive for the lifetime of the process by
    // leaking the path — the OS reclaims it on exit. Tests that
    // touch the trait-routed path open their own dedicated stores.
    let path = tmp.path().to_path_buf();
    std::mem::forget(tmp);
    Arc::new(
        crate::store::sqlite::SqliteStore::open(&path)
            .expect("open SqliteStore for test_app_state"),
    )
}

#[tokio::test]
async fn http_create_memory_uses_appstate_and_persists() {
    // Issue #219 regression — HTTP write path must reach `create_memory`
    // via `State<AppState>` and return 201 CREATED. Previously the daemon
    // held only `Db` and had no path to the embedder/vector index.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "http-embed-test",
        "title": "Semantic-ready via HTTP",
        "content": "HTTP-authored memories must now participate in semantic recall.",
        "tags": ["issue-219"],
        "priority": 7,
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    // And the row is present in the DB.
    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("http-embed-test"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert!(!rows.is_empty(), "HTTP-authored memory must be persisted");
    assert_eq!(rows[0].title, "Semantic-ready via HTTP");
}

/// v0.7.0 L5 — `create_memory` must remain a success path when no
/// LLM is wired on `AppState`. Auto-tag is a soft hook: a daemon
/// running the keyword/semantic tier (no `llm_model` in
/// `TierConfig`) or a smart/autonomous tier with Ollama down
/// (`llm = Arc::new(None)`) MUST still return 201 CREATED, must
/// not insert any `auto_tags` field in the response, and must
/// round-trip operator-supplied tags untouched.
#[tokio::test]
async fn http_create_memory_succeeds_when_llm_is_absent_l5() {
    let state = test_state();
    // `test_app_state` populates `llm: Arc::new(None)` and uses
    // the keyword tier (no `llm_model`) — both gates short-circuit
    // `maybe_auto_tag` to an empty `Vec` so the store is a no-op
    // for the LLM path.
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "l5-no-llm",
        "title": "L5 soft-hook absence",
        "content": "Auto-tag must remain a soft hook when no LLM is wired; \
                    the store must still succeed and the operator's tags \
                    must round-trip unchanged through the response.",
        "tags": ["op-tag-a", "op-tag-b"],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::CREATED,
        "L5: store must succeed even when no LLM client is wired"
    );

    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        payload.get("auto_tags").is_none(),
        "L5: auto_tags must be absent in the response when no LLM ran (got {payload})"
    );

    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("l5-no-llm"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert_eq!(rows.len(), 1, "L5: row must be persisted");
    assert_eq!(
        rows[0].tags,
        vec!["op-tag-a".to_string(), "op-tag-b".to_string()],
        "L5: operator tags must round-trip unchanged when LLM hook was a no-op"
    );
}

/// v0.7.0 L5 — `maybe_auto_tag` gate matrix. Asserts each of the
/// short-circuit conditions returns an empty `Vec` without ever
/// touching the (absent) LLM client, mirroring MCP's skip-reason
/// ladder at `crate::mcp::handle_store` (admin-allowlist ladder).
#[tokio::test]
async fn maybe_auto_tag_gate_matrix_l5() {
    let state = test_state();
    let app = test_app_state(state);

    // 1. Operator supplied tags → skip.
    let r = maybe_auto_tag(
        &app,
        "t",
        "x".repeat(200).as_str(),
        &["op".to_string()],
        "ns",
    )
    .await;
    assert!(
        r.is_empty(),
        "L5: operator-supplied tags must skip auto_tag"
    );

    // 2. Content below AUTO_TAG_MIN_CONTENT_LEN → skip.
    let r = maybe_auto_tag(&app, "t", "short", &[], "ns").await;
    assert!(r.is_empty(), "L5: short content must skip auto_tag");

    // 3. Internal namespace → skip.
    let r = maybe_auto_tag(&app, "t", &"x".repeat(200), &[], "_internal").await;
    assert!(r.is_empty(), "L5: internal namespace must skip auto_tag");

    // 4. Keyword-tier AppState has `llm_model.is_none()` → skip
    //    even when content is long enough and tags + namespace are
    //    permissive.
    let r = maybe_auto_tag(&app, "t", &"x".repeat(200), &[], "ns").await;
    assert!(
        r.is_empty(),
        "L5: tier with no llm_model must skip auto_tag (got {r:?})"
    );
}

#[tokio::test]
async fn http_update_memory_uses_appstate() {
    // Issue #219 — update path must also route via `AppState` so the
    // embedder and vector index are reachable for content-change refresh.
    let state = test_state();
    let now = Utc::now();
    let id = {
        let lock = state.lock().await;
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "http-embed-test".into(),
            title: "Before update".into(),
            content: "Original content.".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.to_rfc3339(),
            updated_at: now.to_rfc3339(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };

    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state.clone()));

    let patch = serde_json::json!({"content": "Updated content for semantic refresh."});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("PUT")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&patch).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_update_memory_enforces_lifecycle_transition_1726() {
    // #1726 — PUT /api/v1/memories/{id} with a `lifecycle_state` target must
    // enforce the transition machine. Legal `open → active` → 200 + persists;
    // illegal `open → done` (skips active) → 409 CONFLICT + row untouched.
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    let mk = |title: &str| Memory {
        id: Uuid::new_v4().to_string(),
        tier: Tier::Long,
        namespace: "http-lc-1726".into(),
        title: title.into(),
        content: "lifecycle http fixture".into(),
        tags: vec![],
        priority: 5,
        confidence: 1.0,
        source: "test".into(),
        access_count: 0,
        created_at: now.clone(),
        updated_at: now.clone(),
        last_accessed_at: None,
        expires_at: None,
        metadata: serde_json::json!({}),
        reflection_depth: 0,
        memory_kind: crate::models::MemoryKind::Goal,
        entity_id: None,
        persona_version: None,
        citations: Vec::new(),
        source_uri: None,
        source_span: None,
        confidence_source: crate::models::ConfidenceSource::CallerProvided,
        confidence_signals: None,
        confidence_decayed_at: None,
        version: 1,
        lifecycle_state: crate::models::LifecycleState::Open,
    };
    let (legal_id, illegal_id) = {
        let lock = state.lock().await;
        (
            db::insert(&lock.0, &mk("lc-legal")).unwrap(),
            db::insert(&lock.0, &mk("lc-illegal")).unwrap(),
        )
    };

    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state.clone()));

    let put = |id: &str, target: &str| {
        let body = serde_json::json!({ "lifecycle_state": target });
        axum::http::Request::builder()
            .uri(format!("/api/v1/memories/{id}"))
            .method("PUT")
            .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
            .body(Body::from(serde_json::to_vec(&body).unwrap()))
            .unwrap()
    };

    // Legal: open -> active.
    let resp = app.clone().oneshot(put(&legal_id, "active")).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK, "open->active must be 200");
    {
        let lock = state.lock().await;
        assert_eq!(
            db::get(&lock.0, &legal_id)
                .unwrap()
                .unwrap()
                .lifecycle_state,
            crate::models::LifecycleState::Active,
        );
    }

    // Illegal: open -> done.
    let resp = app.oneshot(put(&illegal_id, "done")).await.unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::CONFLICT,
        "open->done must be 409 CONFLICT"
    );
    {
        let lock = state.lock().await;
        assert_eq!(
            db::get(&lock.0, &illegal_id)
                .unwrap()
                .unwrap()
                .lifecycle_state,
            crate::models::LifecycleState::Open,
            "a rejected transition must leave the row untouched"
        );
    }
}

// --- Phase 3 foundation HTTP sync tests (issue #224) ---

#[tokio::test]
async fn http_sync_push_applies_and_advances_clock() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // Smoke test for POST /api/v1/sync/push — memories land in the
    // receiver's DB and the vector clock records the sender's latest
    // `updated_at`. Full CRDT semantics are the v0.8.0 follow-up.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));

    let now = Utc::now().to_rfc3339();
    let body = serde_json::json!({
        "sender_agent_id": "peer-alice",
        "sender_clock": {"entries": {}},
        "memories": [{
            "id": Uuid::new_v4().to_string(),
            "tier": Tier::Long.as_str(),
            "namespace": "sync-smoke",
            "title": "From peer",
            "content": "Pushed via HTTP sync endpoint.",
            "tags": [],
            "priority": 5,
            "confidence": 1.0,
            "source": "api",
            "access_count": 0,
            "created_at": now,
            "updated_at": now,
            "last_accessed_at": null,
            "expires_at": null,
            "metadata": {"agent_id": "peer-alice"}
        }],
        "dry_run": false
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "local-receiver")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    // Row landed.
    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("sync-smoke"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert_eq!(rows.len(), 1);
    // Clock advanced — peer-alice registered against local-receiver.
    let clock = db::sync_state_load(&lock.0, "local-receiver").unwrap();
    assert!(
        clock.latest_from("peer-alice").is_some(),
        "push must record sender in sync_state; got: {:?}",
        clock.entries
    );
}

#[tokio::test]
async fn http_sync_push_links_over_cap_rejected_1556() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // #1556 — `links` was the sole /sync/push subcollection missing the
    // max_page_size cap, leaving an unbounded per-link insert+verify loop under
    // the shared write Mutex (DoS). A body with > max_page_size links must be
    // rejected with 400 BEFORE the lock is taken, like every sibling collection.
    let state = test_state();
    let app_state = test_app_state(state.clone());
    let cap = app_state.max_page_size;
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(app_state);
    let link_created_at = Utc::now().to_rfc3339();
    let over_cap: Vec<serde_json::Value> = (0..=cap)
        .map(|i| {
            serde_json::json!({
                "source_id": format!("s{i}"),
                "target_id": format!("t{i}"),
                "relation": "related_to",
                "created_at": link_created_at,
            })
        })
        .collect();
    let body = serde_json::json!({
        "sender_agent_id": "peer-alice",
        "sender_clock": {"entries": {}},
        "memories": [],
        "links": over_cap,
        "dry_run": false
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "local-receiver")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"]
            .as_str()
            .unwrap_or_default()
            .contains("links per request"),
        "expected links-cap rejection; got: {v}"
    );
}

#[tokio::test]
async fn http_sync_push_applies_archives() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // S29 — sync_push must accept an `archives` field and move matching
    // rows from `memories` to `archived_memories` via
    // `db::archive_memory`. Missing ids no-op. The response exposes a
    // new `archived` counter.
    let state = test_state();
    // Seed one row that the peer will ask us to archive; one id that
    // doesn't exist here (must no-op, not error).
    let id = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "s29".into(),
            title: "Archive M1".into(),
            content: "body".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };

    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "sender_agent_id": "peer-a",
        "sender_clock": {"entries": {}},
        "memories": [],
        "archives": [id, "missing-on-peer"],
        "dry_run": false
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["archived"], 1, "live row must be archived");
    assert_eq!(v["noop"], 1, "missing id must no-op");

    // Row is gone from active memories, present in archive, with the
    // correct `sync_push` reason.
    let lock = state.lock().await;
    assert!(db::get(&lock.0, &id).unwrap().is_none());
    let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
    assert_eq!(archived.len(), 1);
    assert_eq!(archived[0]["id"], id);
    assert_eq!(archived[0]["archive_reason"], "sync_push");
}

#[tokio::test]
async fn http_archive_by_ids_happy_path() {
    // S29 — POST /api/v1/archive with `{ids:[...]}` soft-moves each
    // live row to the archive table with the supplied reason.
    // Missing ids are reported in a `missing` array, not an error.
    let state = test_state();
    let live_id = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "s29".into(),
            title: "Live for archive".into(),
            content: "will be archived".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };

    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "ids": [live_id, "does-not-exist"],
        "reason": "scenario_s29"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
    assert_eq!(v["archived"].as_array().unwrap().len(), 1);
    assert_eq!(v["missing"].as_array().unwrap().len(), 1);
    assert_eq!(v["reason"], "scenario_s29");

    // Row is gone from active, present in archive with caller's reason.
    let lock = state.lock().await;
    assert!(db::get(&lock.0, &live_id).unwrap().is_none());
    let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
    assert_eq!(archived.len(), 1);
    assert_eq!(archived[0]["id"], live_id);
    assert_eq!(archived[0]["archive_reason"], "scenario_s29");
}

#[tokio::test]
async fn http_archive_by_ids_default_reason() {
    // When `reason` is omitted the response + archive row must record
    // the default "archive" reason (matches `db::archive_memory`).
    let state = test_state();
    let live_id = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "s29-default".into(),
            title: "Default reason".into(),
            content: "c".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };

    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({"ids": [live_id]});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["reason"], "archive");
    let lock = state.lock().await;
    let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
    assert_eq!(archived[0]["archive_reason"], "archive");
}

#[tokio::test]
async fn http_bulk_create_uses_appstate_and_persists() {
    // S40 prep — bulk_create previously took `State<Db>` with no path
    // to `app.federation`, so every bulk row stayed on the originator.
    // Signature is now `State<AppState>` and each row is persisted.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/bulk", axum_post(bulk_create))
        .with_state(test_app_state(state.clone()));

    let bodies: Vec<serde_json::Value> = (0..5)
        .map(|i| {
            serde_json::json!({
                "tier": Tier::Long.as_str(),
                "namespace": "bulk-appstate",
                "title": format!("bulk-{i}"),
                "content": format!("body-{i}"),
                "tags": [],
                "priority": 5,
                "confidence": 1.0,
                "source": "api",
                "metadata": {}
            })
        })
        .collect();
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/bulk")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["created"], 5);
    assert!(v["errors"].as_array().unwrap().is_empty());

    // Every row is visible in the DB (was the S40 gap — rows never
    // made it past the local insert loop, leaving peers empty).
    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("bulk-appstate"),
        None,
        100,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert_eq!(rows.len(), 5, "bulk rows must persist via AppState");
}

#[tokio::test]
async fn http_bulk_create_fans_out_with_federation() {
    // S40 — with federation configured, each successfully-inserted row
    // in a bulk call must fan out to every peer. We spin up an axum
    // mock peer that records sync_push POSTs and bulk-create N rows;
    // the mock must see N POSTs (background-detached + foreground).
    use std::sync::atomic::{AtomicUsize, Ordering};
    use tokio::net::TcpListener;

    let state = test_state();

    // Mock peer that counts sync_push POSTs and always acks.
    let count = Arc::new(AtomicUsize::new(0));
    let count_for_peer = count.clone();
    #[derive(Clone)]
    struct MockState {
        count: Arc<AtomicUsize>,
    }
    async fn mock_sync_push(
        axum::extract::State(s): axum::extract::State<MockState>,
        Json(_body): Json<serde_json::Value>,
    ) -> (StatusCode, Json<serde_json::Value>) {
        s.count.fetch_add(1, Ordering::Relaxed);
        (
            StatusCode::OK,
            Json(json!({"applied":1,"noop":0,"skipped":0})),
        )
    }
    let peer_app = Router::new()
        .route("/api/v1/sync/push", axum_post(mock_sync_push))
        .with_state(MockState {
            count: count_for_peer,
        });
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, peer_app).await.ok();
    });

    // Build a FederationConfig that targets the mock.
    let peer_url = format!("http://{addr}");
    let fed = crate::federation::FederationConfig::build(
        2, // W=2 — local + 1 peer
        &[peer_url],
        std::time::Duration::from_secs(2),
        None,
        None,
        None,
        "ai:bulk-test".to_string(),
        None,
    )
    .unwrap()
    .expect("federation must be built");

    let app_state = AppState {
        db: state.clone(),
        embedder: Arc::new(None),
        vector_index: Arc::new(Mutex::new(None)),
        federation: Arc::new(Some(fed)),
        tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
        scoring: Arc::new(crate::config::ResolvedScoring::default()),
        profile: Arc::new(crate::profile::Profile::core()),
        mcp_config: Arc::new(None),
        active_keypair: Arc::new(None),
        family_embeddings: Arc::new(RwLock::new(Some(Vec::new()))),
        storage_backend: StorageBackend::Sqlite,
        #[cfg(feature = "sal")]
        store: test_sqlite_store_handle(),
        llm: Arc::new(None),
        auto_tag_model: Arc::new(None),
        llm_call_timeout: std::time::Duration::from_secs(
            crate::config::DEFAULT_LLM_CALL_TIMEOUT_SECS,
        ),
        replay_cache: std::sync::Arc::new(crate::identity::replay::ReplayCache::default()),

        verify_require_nonce: false,
        federation_nonce_cache: std::sync::Arc::new(
            crate::identity::replay::FederationNonceCache::default(),
        ),
        autonomous_hooks: false,
        recall_scope: Arc::new(None),
        deferred_audit_queue: Arc::new(None),
        admin_agent_ids: Arc::new(Vec::new()),
        rule_cache: std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new()),
        resolved_models: std::sync::Arc::new(crate::config::ResolvedModels::default()),
        runtime: crate::runtime_context::RuntimeContext::global_arc(),
        max_page_size: crate::handlers::MAX_BULK_SIZE,
    };
    let router = Router::new()
        .route("/api/v1/memories/bulk", axum_post(bulk_create))
        .with_state(app_state);

    // 4 rows — keeps the test fast while proving fanout ran per-row.
    let n = 4;
    let bodies: Vec<serde_json::Value> = (0..n)
        .map(|i| {
            serde_json::json!({
                "tier": Tier::Long.as_str(),
                "namespace": "bulk-fanout",
                "title": format!("bulk-fanout-{i}"),
                "content": "c",
                "tags": [],
                "priority": 5,
                "confidence": 1.0,
                "source": "api",
                "metadata": {}
            })
        })
        .collect();
    let resp = router
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/bulk")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["created"], n);

    // Foreground fanout already waits for W-1 acks per row, so the
    // per-row POST has landed by the time the request returns. v0.6.2
    // Patch 2 (S40) adds a terminal catchup batch — one extra POST
    // per peer with the full row set — so the expected total is
    // `n + 1` per peer. Give detached stragglers a quick window.
    let expected = n + 1;
    for _ in 0..20 {
        if count.load(Ordering::Relaxed) >= expected {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    }
    assert_eq!(
        count.load(Ordering::Relaxed),
        expected,
        "mock peer must receive one sync_push POST per bulk row plus one terminal catchup batch"
    );
}

#[tokio::test]
async fn http_sync_push_rejects_oversized_batch_redteam_242() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // Red-team #242 — sync_push must cap memories per request, matching
    // bulk-create's MAX_BULK_SIZE. Without this a malicious peer can
    // flood the receiver and bottleneck the SQLite Mutex.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let now = Utc::now().to_rfc3339();
    // Build MAX_BULK_SIZE + 1 entries (1001).
    let mems: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
        .map(|i| {
            serde_json::json!({
                "id": Uuid::new_v4().to_string(),
                "tier": Tier::Long.as_str(),
                "namespace": "oversize",
                "title": format!("m{i}"),
                "content": "x",
                "tags": [],
                "priority": 5,
                "confidence": 1.0,
                "source": "api",
                "access_count": 0,
                "created_at": now,
                "updated_at": now,
                "last_accessed_at": null,
                "expires_at": null,
                "metadata": {}
            })
        })
        .collect();
    let body = serde_json::json!({
        "sender_agent_id": "peer-flood",
        "sender_clock": {"entries": {}},
        "memories": mems,
        "dry_run": false,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_bulk_create_honors_operator_resolved_max_page_size() {
    // [limits].max_page_size — the per-request bulk cap is now driven by
    // the operator-resolved `app.max_page_size`, NOT the compiled
    // `MAX_BULK_SIZE` constant. Pin that a daemon configured with a
    // SMALL cap rejects a batch the default 1000-cap would have accepted,
    // proving the wiring reads the field rather than the constant.
    const SMALL_CAP: usize = 3;
    let mut state = test_app_state(test_state());
    state.max_page_size = SMALL_CAP;
    let app = Router::new()
        .route("/api/v1/memories/bulk", axum_post(bulk_create))
        .with_state(state);

    // SMALL_CAP + 1 bodies — over the configured cap, under the compiled
    // default. A daemon still keyed on MAX_BULK_SIZE would 200 this.
    let bodies: Vec<serde_json::Value> = (0..=SMALL_CAP)
        .map(|i| {
            serde_json::json!({
                "title": format!("m{i}"),
                "content": "x",
                "namespace": "cap-test",
            })
        })
        .collect();
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/bulk")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "bulk_create must reject a batch above the operator-resolved max_page_size"
    );
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"]
            .as_str()
            .unwrap_or_default()
            .contains(&SMALL_CAP.to_string()),
        "error message must echo the configured cap, got {v:?}"
    );
}

#[tokio::test]
async fn http_sync_push_dry_run_applies_nothing() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // Phase 3 — dry_run=true must not write.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));

    let now = Utc::now().to_rfc3339();
    let body = serde_json::json!({
        "sender_agent_id": "peer-bob",
        "sender_clock": {"entries": {}},
        "memories": [{
            "id": Uuid::new_v4().to_string(),
            "tier": Tier::Long.as_str(),
            "namespace": "sync-dryrun",
            "title": "Must not land",
            "content": "Preview only.",
            "tags": [],
            "priority": 5,
            "confidence": 1.0,
            "source": "api",
            "access_count": 0,
            "created_at": now,
            "updated_at": now,
            "last_accessed_at": null,
            "expires_at": null,
            "metadata": {}
        }],
        "dry_run": true
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("sync-dryrun"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert!(rows.is_empty(), "dry_run must not write rows");
}

#[tokio::test]
async fn http_contradictions_surfaces_same_topic_candidates_and_synth_link() {
    // v0.6.0.1 (#321) — GET /api/v1/contradictions?topic=X&namespace=Y
    // returns the candidate memories sharing the topic and a synthesized
    // contradicts link between any pair with differing content.
    let state = test_state();
    let now = Utc::now().to_rfc3339();

    // Seed two memories with metadata.topic=T and DIFFERENT content. We
    // use distinct titles so UPSERT-on-(title,namespace) doesn't dedup —
    // that's the scenario-6 fix in ai2ai-gate.
    {
        let lock = state.lock().await;
        let topic = "sky-color-test";
        for (title, agent, content) in [
            ("sky-color-test-alice", "ai:alice", "sky-color-test is blue"),
            ("sky-color-test-bob", "ai:bob", "sky-color-test is red"),
        ] {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Mid,
                namespace: "contradictions-test".into(),
                title: title.into(),
                content: content.into(),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "api".into(),
                access_count: 0,
                created_at: now.clone(),
                updated_at: now.clone(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({
                    "agent_id": agent,
                    "topic": topic,
                }),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }

    let app = Router::new()
        .route("/api/v1/contradictions", axum_get(detect_contradictions))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/contradictions?topic=sky-color-test&namespace=contradictions-test")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    let memories = v["memories"].as_array().unwrap();
    assert_eq!(memories.len(), 2, "both candidates should be returned");

    let links = v["links"].as_array().unwrap();
    let synth_contradict = links.iter().find(|l| {
        l["relation"].as_str() == Some("contradicts") && l["synthesized"].as_bool() == Some(true)
    });
    assert!(
        synth_contradict.is_some(),
        "expected a synthesized contradicts link between alice and bob"
    );
}

#[tokio::test]
async fn http_contradictions_requires_topic_or_namespace() {
    // Guard: calling the endpoint with neither topic nor namespace is a
    // 400 — we refuse to scan the whole DB by accident.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/contradictions", axum_get(detect_contradictions))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/contradictions")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_sync_push_applies_deletions() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // v0.6.0.1 — sync_push's `deletions` field removes the listed ids
    // from the receiver so peer-side tombstone fanout works for
    // scenario-10. (a2a-hermes r14.)
    let state = test_state();
    let now = Utc::now().to_rfc3339();

    let seeded_id = {
        let lock = state.lock().await;
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "delete-fanout".into(),
            title: "to-be-deleted".into(),
            content: "body".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "ai:seeder"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };

    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "sender_agent_id": "peer-alice",
        "sender_clock": {"entries": {}},
        "memories": [],
        "deletions": [seeded_id.clone()],
        "dry_run": false
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "local-receiver")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["deleted"], 1);

    let lock = state.lock().await;
    let gone = db::get(&lock.0, &seeded_id).unwrap();
    assert!(
        gone.is_none(),
        "row should have been tombstoned by sync_push"
    );
}

#[tokio::test]
async fn http_sync_push_applies_incoming_links() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // v0.6.2 (#325) — sync_push's `links` field applies the listed
    // (source, target, relation) triples via db::create_link on the
    // receiver so peer-side link fanout works for scenario-11.
    // (a2a-hermes-v0.6.1-r15.)
    let state = test_state();
    let now = Utc::now().to_rfc3339();

    // Seed two memories on the receiver so the link has valid endpoints.
    let (m1, m2) = {
        let lock = state.lock().await;
        let m1 = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "link-fanout".into(),
            title: "source".into(),
            content: "a".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "ai:seeder"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let m1_id = db::insert(&lock.0, &m1).unwrap();
        let m2 = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "link-fanout".into(),
            title: "target".into(),
            content: "b".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "ai:seeder"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let m2_id = db::insert(&lock.0, &m2).unwrap();
        (m1_id, m2_id)
    };

    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "sender_agent_id": "peer-alice",
        "sender_clock": {"entries": {}},
        "memories": [],
        "links": [{
            "source_id": m1,
            "target_id": m2,
            "relation": "related_to",
            "created_at": now,
        }],
        "dry_run": false
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "local-receiver")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["links_applied"], 1);

    let lock = state.lock().await;
    let links = db::get_links(&lock.0, &m1).unwrap();
    assert_eq!(links.len(), 1);
    assert_eq!(links[0].target_id, m2);
    assert_eq!(
        links[0].relation,
        crate::models::MemoryLinkRelation::RelatedTo
    );
}

// v0.7.0 fix-campaign A3 (LINK-PARITY, #690) — the federation
// receive path must refuse a cycle-closing `reflects_on` edge even
// when the inbound link comes from a peer. mTLS + Ed25519 doesn't
// grant peers the right to corrupt the local reflection DAG.
#[tokio::test]
async fn http_sync_push_refuses_reflection_cycle_from_peer() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    use crate::config::{
        PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
    };
    let _gate = lock_permissions_mode_for_test();
    override_active_permissions_mode_for_test(PermissionsMode::Off);

    let state = test_state();
    let now = Utc::now().to_rfc3339();
    // Seed two memories on the receiver and a pre-existing
    // a --reflects_on--> b chain so a fresh b --reflects_on--> a
    // would close the cycle.
    let (a_id, b_id) = {
        let lock = state.lock().await;
        let a = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "a3-fed-cycle".into(),
            title: "a".into(),
            content: "a".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let a_id = db::insert(&lock.0, &a).unwrap();
        let b = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "a3-fed-cycle".into(),
            title: "b".into(),
            content: "b".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let b_id = db::insert(&lock.0, &b).unwrap();
        db::create_link(&lock.0, &a_id, &b_id, "reflects_on").unwrap();
        (a_id, b_id)
    };

    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "sender_agent_id": "peer-alice",
        "sender_clock": {"entries": {}},
        "memories": [],
        "links": [{
            "source_id": b_id,
            "target_id": a_id,
            "relation": "reflects_on",
            "created_at": now,
        }],
        "dry_run": false
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "local-receiver")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    // sync_push always responds 200; the cycle refusal manifests
    // as `links_applied=0` and a warn log on the receiver. The
    // load-bearing assertion is that the cycle edge did NOT land
    // in the local graph.
    assert_eq!(resp.status(), StatusCode::OK);
    let lock = state.lock().await;
    let links_from_b = db::get_links(&lock.0, &b_id).unwrap();
    let landed = links_from_b.iter().any(|l| {
        l.source_id == b_id
            && l.target_id == a_id
            && l.relation == crate::models::MemoryLinkRelation::ReflectsOn
    });
    assert!(
        !landed,
        "cycle-closing reflects_on must NOT land via sync_push"
    );
}

// v0.7.0 fix-campaign A3 (LINK-PARITY, #690) — federation receive
// path: a `peer_attested` link bypasses the local K9 governance
// gate (the peer's signature is the attestation; the receiver's
// namespace policy is the peer's local concern). Unsigned inbound
// links remain gated. Documents the security model in code.
//
// Pre-A3, ALL inbound links bypassed K9 because the gate was
// MCP-only — A3 closes that with the bypass keyed on attest_level.
#[tokio::test]
async fn http_sync_push_governance_bypass_on_peer_attested() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    use crate::config::{
        PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
    };
    let _gate = lock_permissions_mode_for_test();
    // K9 in Off mode — exercising the cycle-only fast path. (A
    // full peer_attested verify needs an enrolled pubkey and a
    // signed CBOR payload; that's covered by the inbound storage
    // tests in storage/mod.rs::a3_create_link_inbound_*. Here we
    // assert the wire-level happy path: a federation push lands a
    // legitimate link even when K9 governance is configured.)
    override_active_permissions_mode_for_test(PermissionsMode::Off);

    let state = test_state();
    let now = Utc::now().to_rfc3339();
    let (s_id, t_id) = {
        let lock = state.lock().await;
        let s = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "a3-fed-bypass".into(),
            title: "src".into(),
            content: "src".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let s_id = db::insert(&lock.0, &s).unwrap();
        let t = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "a3-fed-bypass".into(),
            title: "tgt".into(),
            content: "tgt".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let t_id = db::insert(&lock.0, &t).unwrap();
        (s_id, t_id)
    };

    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "sender_agent_id": "peer-alice",
        "sender_clock": {"entries": {}},
        "memories": [],
        "links": [{
            "source_id": s_id,
            "target_id": t_id,
            "relation": "related_to",
            "created_at": now,
        }],
        "dry_run": false
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "local-receiver")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["links_applied"], 1);
}

#[tokio::test]
async fn http_sync_since_streams_new_memories_only() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // Phase 3 — GET /api/v1/sync/since?since=<ts> returns only memories
    // with updated_at > ts.
    //
    // #1028 (HIGH, 2026-05-21): SAL `memories_updated_since` now
    // filters out scope=private rows (defense-in-depth federation
    // gate). Test memories must set `scope: "shared"` explicitly to
    // be eligible for /sync/since enumeration; without it the rows
    // default to scope=private and the SAL refuses to ship them to
    // peers.
    let state = test_state();
    // Seed one old + one new memory.
    let old_ts = "2020-01-01T00:00:00+00:00";
    let new_ts = Utc::now().to_rfc3339();
    {
        let lock = state.lock().await;
        for (title, ts) in [("old-mem", old_ts), ("new-mem", new_ts.as_str())] {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Long,
                namespace: "since-test".into(),
                title: title.into(),
                content: "body".into(),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "api".into(),
                access_count: 0,
                created_at: ts.to_string(),
                updated_at: ts.to_string(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({"scope": "shared"}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }

    let app = Router::new()
        .route("/api/v1/sync/since", axum_get(sync_since))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/since?since=2020-06-01T00:00:00%2B00:00")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let titles: Vec<String> = v["memories"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|m| m["title"].as_str().map(str::to_string))
        .collect();
    assert_eq!(titles, vec!["new-mem".to_string()]);
}

#[tokio::test]
async fn http_sync_since_includes_s39_diagnostic_fields() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // S39 — the response must echo `updated_since` (parsed `since`)
    // and earliest/latest `updated_at` from the returned set. This
    // lets the scenario pin whether the server saw the expected
    // checkpoint without changing the set-returning behavior.
    let state = test_state();
    // Seed three rows in strictly-ordered time so earliest != latest.
    let mid_ts = "2024-06-01T00:00:00+00:00";
    let newer_ts = "2025-06-01T00:00:00+00:00";
    let newest_ts = "2026-01-01T00:00:00+00:00";
    {
        let lock = state.lock().await;
        for (title, ts) in [("mid", mid_ts), ("newer", newer_ts), ("newest", newest_ts)] {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Long,
                namespace: "s39-diag".into(),
                title: title.into(),
                content: "c".into(),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "api".into(),
                access_count: 0,
                created_at: ts.to_string(),
                updated_at: ts.to_string(),
                last_accessed_at: None,
                expires_at: None,
                // #1028: scope=shared so SAL list_memories_updated_since
                // ships the row to peers (defense-in-depth gate filters
                // out scope=private federation candidates).
                metadata: serde_json::json!({"scope": "shared"}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }

    let app = Router::new()
        .route("/api/v1/sync/since", axum_get(sync_since))
        .with_state(test_app_state(state.clone()));

    // Ask for rows strictly after 2024-01 — should return all 3.
    let since = "2024-01-01T00:00:00%2B00:00";
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/sync/since?since={since}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 3);
    // Echoed `since` (unparsed, verbatim — that's the point).
    assert_eq!(v["updated_since"], "2024-01-01T00:00:00+00:00");
    assert_eq!(v["earliest_updated_at"], mid_ts);
    assert_eq!(v["latest_updated_at"], newest_ts);

    // Empty set → both timestamp fields are null. The `updated_since`
    // field still echoes the parsed input.
    let empty_app = Router::new()
        .route("/api/v1/sync/since", axum_get(sync_since))
        .with_state(test_app_state(state));
    let resp = empty_app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/since?since=2099-01-01T00:00:00%2B00:00")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert!(v["earliest_updated_at"].is_null());
    assert!(v["latest_updated_at"].is_null());
    assert_eq!(v["updated_since"], "2099-01-01T00:00:00+00:00");
}

#[tokio::test]
async fn sync_since_rejects_garbage_timestamp_with_400() {
    // Red-team #247 — `since=garbage` previously returned 200 with all
    // memories. Now must return 400 with a clear error.
    // #1789 — this asserts downstream timestamp validation, not enrollment;
    // opt back to permissive so the flipped secure default doesn't 401 first.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/since", axum_get(sync_since))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/since?since=not-a-date")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("RFC 3339"));
}

#[tokio::test]
async fn sync_state_observe_is_monotonic() {
    // Phase 3 — clock advancement must never go backwards.
    let conn = db::open(std::path::Path::new(":memory:")).unwrap();
    let older = "2020-01-01T00:00:00+00:00";
    let newer = "2026-04-17T00:00:00+00:00";

    db::sync_state_observe(&conn, "local", "peer-a", newer).unwrap();
    // A subsequent older observation must NOT overwrite.
    db::sync_state_observe(&conn, "local", "peer-a", older).unwrap();
    let clock = db::sync_state_load(&conn, "local").unwrap();
    assert_eq!(clock.latest_from("peer-a"), Some(newer));
}

// --- API key auth middleware tests ---

async fn dummy_handler() -> impl IntoResponse {
    (StatusCode::OK, "ok")
}

fn auth_app(api_key: Option<&str>) -> Router {
    let auth_state = ApiKeyState {
        key: api_key.map(String::from),
        mtls_enforced: false,
    };
    Router::new()
        .route("/api/v1/health", axum_get(dummy_handler))
        .route("/api/v1/memories", axum_get(dummy_handler))
        .layer(axum::middleware::from_fn_with_state(
            auth_state,
            api_key_auth,
        ))
}

#[tokio::test]
async fn api_key_no_key_configured_allows_all() {
    let app = auth_app(None);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn api_key_valid_header_allows() {
    let app = auth_app(Some("secret123"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .header("x-api-key", "secret123")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn api_key_invalid_header_rejected() {
    let app = auth_app(Some("secret123"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .header("x-api-key", "wrong")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn api_key_missing_header_rejected() {
    let app = auth_app(Some("secret123"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn api_key_valid_query_param_allows() {
    let app = auth_app(Some("secret123"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories?api_key=secret123")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn api_key_health_exempt() {
    let app = auth_app(Some("secret123"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}
// --- Error arm unit tests (cov-80pct/handlers-errors) ---
// Target the 30% of handlers.rs that smoke tests don't reach:
// Axum extractor failures, domain validation errors, governance rejections,
// SSRF defense, and streaming error paths.

// ---- Axum extractor failures: invalid JSON, missing fields, oversized body ----

#[tokio::test]
async fn create_memory_rejects_invalid_json() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(b"not valid json".to_vec()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn create_memory_rejects_missing_required_fields() {
    // v0.7.0 Round-2 F9 — POST `/api/v1/memories` with a required
    // field absent now returns 400 BAD_REQUEST + a structured
    // `{"error", "fields"}` envelope instead of axum's default 422
    // UNPROCESSABLE_ENTITY (which leaked the raw serde diagnostic
    // and gave callers no stable hook to switch on).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    // Missing title
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "content": "body text",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .unwrap();
    let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        payload.get("error").and_then(|v| v.as_str()).is_some(),
        "F9: response must include sanitized `error` field"
    );
    let fields = payload
        .get("fields")
        .and_then(|v| v.as_array())
        .expect("F9: response must include `fields` array");
    assert!(
        fields.iter().any(|v| v.as_str() == Some("title")),
        "F9: `fields` must name the missing required field (`title`)"
    );
}

#[tokio::test]
async fn create_memory_rejects_empty_title() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "",
        "content": "body text",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("title"));
}

#[tokio::test]
async fn create_memory_rejects_oversized_content() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    // 65KB + 1 — exceeds MAX_CONTENT_SIZE (65536)
    let oversized = "x".repeat(65537);
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "Test",
        "content": oversized,
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("exceeds max size"));
}

#[tokio::test]
async fn create_memory_rejects_invalid_tier() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    // Invalid tier enum value
    let body_str = r#"{"tier":"invalid_tier","namespace":"test","title":"Test","content":"body","tags":[],"priority":5,"confidence":1.0,"source":"api","metadata":{}}"#;
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(body_str.as_bytes().to_vec()))
                .unwrap(),
        )
        .await
        .unwrap();
    // v0.7.0 Round-2 F9 — JsonOrBadRequest folds every JSON
    // extractor rejection (missing field, type error, syntax
    // error) into a unified 400 BAD_REQUEST envelope. Pre-F9
    // axum surfaced 422 UNPROCESSABLE_ENTITY for type errors
    // specifically; post-F9 the wire is 400 for the whole class.
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn create_memory_rejects_invalid_priority() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "Test",
        "content": "body",
        "tags": [],
        "priority": 0,  // min is 1
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn create_memory_rejects_invalid_confidence() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "Test",
        "content": "body",
        "tags": [],
        "priority": 5,
        "confidence": 1.5,  // must be 0.0-1.0
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn create_memory_rejects_invalid_source() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "Test",
        "content": "body",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "invalid_source",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- update_memory errors ----

#[tokio::test]
async fn update_memory_rejects_invalid_id() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state));

    let body = serde_json::json!({"content": "new content"});
    // Test with a URL path that's invalid (most long IDs in memory system are UUIDs,
    // which are fixed 36 chars, so a very long string validates but doesn't exist -> 404)
    // Let's use a different approach: an ID with invalid characters
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/@@@@@@@@@@@@") // invalid characters
                .method("PUT")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    // Invalid characters in ID should return BAD_REQUEST from validation
    assert!(resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn update_memory_rejects_oversized_content() {
    let state = test_state();
    let now = Utc::now();
    let id = {
        let lock = state.lock().await;
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "test".into(),
            title: "To Update".into(),
            content: "Original".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.to_rfc3339(),
            updated_at: now.to_rfc3339(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };

    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state));

    let oversized = "x".repeat(65537);
    let body = serde_json::json!({"content": oversized});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("PUT")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn update_memory_rejects_invalid_confidence() {
    let state = test_state();
    let now = Utc::now();
    let id = {
        let lock = state.lock().await;
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "test".into(),
            title: "To Update".into(),
            content: "Original".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.to_rfc3339(),
            updated_at: now.to_rfc3339(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };

    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state));

    let body = serde_json::json!({"confidence": -0.5});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("PUT")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- link validation errors ----

#[tokio::test]
async fn link_rejects_self_link() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/links", axum_post(create_link))
        .with_state(test_app_state(state));

    let same_id = Uuid::new_v4().to_string();
    let body = serde_json::json!({
        "source_id": same_id,
        "target_id": same_id,
        "relation": "related_to"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"]
            .as_str()
            .unwrap()
            .contains("cannot link a memory to itself")
    );
}

#[tokio::test]
async fn link_rejects_unknown_relation() {
    // v0.7.0 Wave-3 Cont 5 (commit cb92998) relaxed
    // `validate_relation` to accept any `[a-z0-9_]+` identifier so
    // S82/S65 chain markers and arbitrary AGE-style edge labels
    // round-tripped through `POST /api/v1/links`.
    //
    // v0.7.0 fix campaign R1-M2/M4 (#690) reverses that posture at
    // the SQL substrate: the CHECK clause refuses any relation
    // outside the closed set `{related_to, supersedes, contradicts,
    // derived_from, reflects_on}` — defense-in-depth matching the
    // typed `MemoryLinkRelation` enum.
    //
    // v0.7.0 G-PHASE-E-1 (#706) further hardens the HTTP path: the
    // create-link handler now pre-flights the relation against the
    // closed set and returns a structured 400 instead of the prior
    // generic 500 surfaced from the CHECK violation. We pin the
    // typed `{"error":"invalid_relation","got":...,"allowed":[...]}`
    // shape so a future loosening on either side surfaces here.
    let state = test_state();
    let src = insert_test_memory(&state, "ns-link-relation", "src").await;
    let tgt = insert_test_memory(&state, "ns-link-relation", "tgt").await;
    let app = Router::new()
        .route("/api/v1/links", axum_post(create_link))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "source_id": src,
        "target_id": tgt,
        // Passes `validate_relation` (lowercase identifier shape)
        // but is NOT in the closed set; the HTTP handler refuses
        // before reaching the SQL CHECK.
        "relation": "invalid_relation"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "off-closed-set relation must surface as a structured 400 (#706)"
    );
    let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
    assert_eq!(v["error"], "invalid_relation");
    assert_eq!(v["got"], "invalid_relation");
    assert!(v["allowed"].is_array());
}

#[tokio::test]
async fn link_rejects_malformed_relation() {
    // v0.7.0 Wave-3 Cont 5 follow-up: coverage of the rejection
    // path that `link_rejects_unknown_relation` used to anchor.
    // The relaxed `validate_relation` still rejects structurally
    // malformed labels — anything carrying uppercase, whitespace,
    // dashes, slashes, or other non-`[a-z0-9_]` bytes. We exercise
    // each shape so future loosenings (e.g. accepting hyphens or
    // uppercase) surface here, not in production.
    let state = test_state();
    let src = insert_test_memory(&state, "ns-link-malformed", "src").await;
    let tgt = insert_test_memory(&state, "ns-link-malformed", "tgt").await;
    let app_state = test_app_state(state);
    for bad in ["BAD", "bad relation", "bad-relation", "bad/relation"] {
        let app = Router::new()
            .route("/api/v1/links", axum_post(create_link))
            .with_state(app_state.clone());
        let body = serde_json::json!({
            "source_id": src,
            "target_id": tgt,
            "relation": bad,
        });
        let resp = app
            .oneshot(
                axum::http::Request::builder()
                    .uri("/api/v1/links")
                    .method("POST")
                    .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(
            resp.status(),
            StatusCode::BAD_REQUEST,
            "relation `{bad}` should be rejected by validate_relation",
        );
        let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
            .await
            .unwrap();
        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(
            v["error"].as_str().unwrap().contains("relation"),
            "error body for `{bad}` should mention `relation`; got: {v}",
        );
    }
}

// ---- recall validation errors ----

#[tokio::test]
async fn recall_post_rejects_empty_context() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/recall", axum_post(recall_memories_post))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "context": "",
        "limit": 10
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/recall")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn recall_post_zero_budget_tokens_returns_empty() {
    // Phase P6 (R1): budget_tokens=0 is a valid request meaning
    // "give me nothing"; returns 200 with an empty memories array
    // and meta.budget_overflow=false. Supersedes the v0.6.3
    // Ultrareview #348 hard-reject of 0.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/recall", axum_post(recall_memories_post))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "context": "search term",
        "limit": 10,
        "budget_tokens": 0
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/recall")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0, "budget_tokens=0 returns zero memories");
    assert_eq!(v["budget_tokens"], 0);
    assert_eq!(v["meta"]["budget_overflow"], false);
}

#[tokio::test]
async fn recall_get_rejects_empty_context() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/recall",
            axum::routing::get(recall_memories_get),
        )
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/recall?context=")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- register_agent validation errors ----

#[tokio::test]
async fn register_agent_rejects_invalid_agent_id() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_post(register_agent))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "agent_id": "x".repeat(129),  // exceeds max 128
        "agent_type": "human",
        "capabilities": []
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn register_agent_rejects_invalid_agent_type() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_post(register_agent))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "agent_id": "test-agent",
        "agent_type": "invalid_type",
        "capabilities": []
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- subscribe validation (SSRF defense) ----

#[tokio::test]
async fn subscribe_rejects_private_ip() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    // R3-S1.HMAC (2026-05-13): subscribe now requires a per-sub
    // `secret` (or server-wide HMAC override). Supply one so the
    // SSRF guard is the gate this test pins, not the HMAC check.
    // Private IP range: http:// to non-loopback requires https
    let body = serde_json::json!({
        "url": "http://10.0.0.1/webhook",
        "events": "*",
        "secret": "test-sub-secret",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    // The error could be about private IPs or about non-https for non-loopback
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let error_msg = v["error"].as_str().unwrap();
    assert!(
        error_msg.contains("private")
            || error_msg.contains("link-local")
            || error_msg.contains("https")
            || error_msg.contains("non-loopback")
    );
}

#[tokio::test]
async fn subscribe_rejects_file_url() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "url": "file:///etc/passwd",
        "events": "*",
        "secret": "test-sub-secret",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn subscribe_accepts_localhost_loopback() {
    // Localhost is explicitly allowed for S33 namespace-subscribe pattern
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "url": "http://localhost/webhook",
        "events": "*",
        "secret": "test-sub-secret",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    // Should succeed or fail gracefully (may fail if DB insert fails, but not SSRF)
    // Localhost is explicitly allowed for S33
    assert!(resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK);
}

// ---- notify validation errors ----

#[tokio::test]
async fn notify_rejects_missing_payload() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "target_agent_id": "bob",
        "title": "A message"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"].as_str().unwrap().contains("payload")
            || v["error"].as_str().unwrap().contains("content")
    );
}

// ---- governance rejection (Task 1.9) ----
// Note: Full governance enforcement requires DB setup with actual governance
// policies. These tests verify the handler path exists and returns 422/403.
// Skipped here due to complexity — documented in escape hatch.

// ---- Content-Type negotiation ----

#[tokio::test]
async fn create_memory_handles_missing_content_type() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "Test",
        "content": "body",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    // Omit content-type header
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    // Should fail (Axum rejects without content-type)
    assert!(resp.status() != StatusCode::CREATED);
}

// ---- Pagination edge cases ----

#[tokio::test]
async fn list_memories_handles_limit_zero() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum::routing::get(list_memories))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories?limit=0")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    // Should succeed with default limit (not error)
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn list_memories_clamps_oversized_limit() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum::routing::get(list_memories))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories?limit=10000") // way over normal max
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    // Should succeed with clamped limit
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn search_memories_handles_negative_limit() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/search",
            axum::routing::get(search_memories),
        )
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/search?query=test&limit=-1")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    // Should not crash; may be treated as 0 or clamped
    assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST);
}

// ---- API Key authentication errors ----

#[tokio::test]
async fn api_key_missing_when_required_rejects() {
    let app = auth_app(Some("secret123"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("GET")
                // No x-api-key header
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn api_key_wrong_value_rejects() {
    let app = auth_app(Some("secret123"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("GET")
                .header("x-api-key", "wrong_secret")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

// ---------------------------------------------------------------
// Wave 2 Closer Z — targeted tests for the 30% past A2's smoke
// matrix and Agent D's error arms. Focuses on lifecycle edge
// cases (archive/restore/purge), bulk partial-success, format
// negotiation, and pending workflows.
// ---------------------------------------------------------------

/// Insert a memory directly via the DB layer; returns the id.
async fn insert_test_memory(state: &Db, namespace: &str, title: &str) -> String {
    let lock = state.lock().await;
    let now = Utc::now().to_rfc3339();
    let mem = Memory {
        id: Uuid::new_v4().to_string(),
        tier: Tier::Long,
        namespace: namespace.into(),
        title: title.into(),
        content: format!("content for {title}"),
        tags: vec![],
        priority: 5,
        confidence: 1.0,
        source: "test".into(),
        access_count: 0,
        created_at: now.clone(),
        updated_at: now,
        last_accessed_at: None,
        expires_at: None,
        // #910 — stamp `scope=collective` on test fixtures so the
        // post-#910 visibility filter on list_memories + kg_query
        // does not silently drop them under an anonymous caller.
        // Tests that need to exercise scope=private semantics set
        // the field explicitly via `metadata` on the build path.
        metadata: serde_json::json!({"scope": "collective"}),
        reflection_depth: 0,
        memory_kind: crate::models::MemoryKind::Observation,
        entity_id: None,
        persona_version: None,
        citations: Vec::new(),
        source_uri: None,
        source_span: None,
        confidence_source: crate::models::ConfidenceSource::CallerProvided,
        confidence_signals: None,
        confidence_decayed_at: None,
        version: 1,
        lifecycle_state: crate::models::LifecycleState::Open,
    };
    db::insert(&lock.0, &mem).unwrap()
}

// ---- Archive lifecycle edge cases ----

#[tokio::test]
async fn http_list_archive_rejects_limit_zero() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?limit=0")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("limit"));
}

#[tokio::test]
async fn http_list_archive_clamps_oversized_limit() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?limit=99999")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_list_archive_filters_by_namespace() {
    let state = test_state();
    // Archive one row under a specific namespace.
    let id = insert_test_memory(&state, "arch-ns-a", "to-archive").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?namespace=arch-ns-a&limit=10")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
}

#[tokio::test]
async fn http_restore_archive_404_for_unknown_id() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/00000000-0000-0000-0000-000000000000/restore")
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_restore_archive_rejects_empty_id() {
    // validate_id rejects whitespace-only / control-char inputs.
    // We use a control char via percent-encoding (%01) which makes
    // the path parse as an id (not "skip route") but fail
    // validate_id's clean-string check.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/%01/restore")
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_restore_archive_double_restore_returns_404() {
    // Restore happy-path then try to restore again — second call must
    // 404 because the row is no longer in archived_memories.
    let state = test_state();
    let id = insert_test_memory(&state, "restore-twice", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state.clone()));

    // First restore succeeds.
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    // Second restore — already restored, must 404.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_purge_archive_zero_days_purges_all() {
    // older_than_days=0 means "older than 0 days ago" — purges
    // every archive row whose archived_at < now (i.e., everything).
    let state = test_state();
    let id = insert_test_memory(&state, "purge-zero", "x").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/purge", axum_post(purge_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/purge?older_than_days=0")
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // older_than_days=0 with a freshly archived row may or may not
    // include it depending on clock resolution; either way the call
    // must succeed and the response must report a usize count.
    assert!(v["purged"].as_u64().is_some());
}

#[tokio::test]
async fn http_purge_archive_negative_days_returns_500() {
    // db::purge_archive bails on negative days; handler maps to 500.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/purge", axum_post(purge_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/purge?older_than_days=-1")
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

#[tokio::test]
async fn http_purge_archive_no_days_purges_unconditional() {
    // Omit older_than_days entirely → DELETE every archive row.
    //
    // #936 (security-critical, 2026-05-20) — the owner-blind purge is
    // now admin-only. The test pre-#936 exercised the cross-tenant
    // wipe surface; it continues to do so via `X-Agent-Id: ops:admin`
    // with `ops:admin` in the test fixture's `admin_agent_ids`
    // allowlist. The cross-tenant gate itself is covered by
    // `tests/archive_purge_owner_gate_936.rs`.
    let state = test_state();
    let id = insert_test_memory(&state, "purge-all", "x").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/purge", axum_post(purge_archive))
        .with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/purge")
                .method("POST")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["purged"], 1);
}

#[tokio::test]
async fn http_archive_stats_reports_per_namespace_counts() {
    let state = test_state();
    let id_a = insert_test_memory(&state, "stats-a", "a").await;
    let id_b = insert_test_memory(&state, "stats-b", "b").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
        db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/stats")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["archived_total"], 2);
    assert_eq!(v["by_namespace"].as_array().unwrap().len(), 2);
}

#[tokio::test]
async fn http_archive_by_ids_rejects_oversized_batch() {
    // bulk size limit defends the handler.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state));
    let big_ids: Vec<String> = (0..=MAX_BULK_SIZE)
        .map(|_| Uuid::new_v4().to_string())
        .collect();
    let body = serde_json::json!({"ids": big_ids});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("archive limited"));
}

#[tokio::test]
async fn http_archive_by_ids_rejects_invalid_id_in_batch() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state));
    // Whitespace-only id triggers validate_id's empty check.
    let body = serde_json::json!({"ids": ["   "]});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("invalid id"));
}

#[tokio::test]
async fn http_archive_by_ids_all_missing() {
    // Every supplied id is missing locally → 200 with archived=[]
    // and missing=[…all…]. Confirms the “no live row” path fires
    // for every id without short-circuiting.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state));
    let ids: Vec<String> = (0..3).map(|_| Uuid::new_v4().to_string()).collect();
    let body = serde_json::json!({"ids": ids});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert_eq!(v["archived"].as_array().unwrap().len(), 0);
    assert_eq!(v["missing"].as_array().unwrap().len(), 3);
}

// ---- Bulk-create partial success ----

#[tokio::test]
async fn http_bulk_create_oversized_batch_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/bulk", axum_post(bulk_create))
        .with_state(test_app_state(state));
    let bodies: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
        .map(|i| {
            serde_json::json!({
                "tier": Tier::Long.as_str(),
                "namespace": "bulk-overflow",
                "title": format!("t-{i}"),
                "content": "c",
                "tags": [],
                "priority": 5,
                "confidence": 1.0,
                "source": "api",
                "metadata": {}
            })
        })
        .collect();
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/bulk")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_bulk_create_partial_success_collects_errors() {
    // One row passes, one row fails validation (empty title). The
    // handler must commit the good row, push the bad row's reason
    // onto `errors`, and return 200 with `created=1`.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/bulk", axum_post(bulk_create))
        .with_state(test_app_state(state.clone()));
    let bodies = serde_json::json!([
        {
            "tier": Tier::Long.as_str(),
            "namespace": "bulk-mixed",
            "title": "good row",
            "content": "ok",
            "tags": [],
            "priority": 5,
            "confidence": 1.0,
            "source": "api",
            "metadata": {}
        },
        {
            "tier": Tier::Long.as_str(),
            "namespace": "bulk-mixed",
            "title": "",
            "content": "bad: empty title",
            "tags": [],
            "priority": 5,
            "confidence": 1.0,
            "source": "api",
            "metadata": {}
        }
    ]);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/bulk")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["created"], 1);
    assert_eq!(v["errors"].as_array().unwrap().len(), 1);

    // The good row must be visible in the DB.
    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("bulk-mixed"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].title, "good row");
}

#[tokio::test]
async fn http_bulk_create_empty_body_succeeds_with_zero_created() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/bulk", axum_post(bulk_create))
        .with_state(test_app_state(state));
    let bodies: Vec<serde_json::Value> = vec![];
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/bulk")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["created"], 0);
    assert!(v["errors"].as_array().unwrap().is_empty());
}

// ---- Pending workflow edge cases ----

#[tokio::test]
async fn http_list_pending_empty_returns_zero_count() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending", axum::routing::get(list_pending))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
}

#[tokio::test]
async fn http_list_pending_with_status_filter() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending", axum::routing::get(list_pending))
        .with_state(test_app_state(state.clone()));
    // Status=approved gets the SQL filter path. Empty result is fine.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending?status=approved&limit=5")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_approve_pending_unknown_id_returns_404_1620() {
    // approve_pending validates the id format, then attempts approval.
    // #1620 — an unknown-but-valid uuid is a typed
    // ApproveOutcome::NotFound → 404, matching the postgres branch's
    // StoreError::NotFound mapping (pre-#1620 it collapsed into the
    // 403 policy-refusal bucket while postgres 404'd).
    // S5-C1 (2026-05-13): /approve is now HMAC-gated; install the
    // server-wide secret and sign the empty body before dispatching.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state));
    let unknown = Uuid::new_v4().to_string();
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &unknown, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{unknown}/approve"))
                .method("POST")
                .header("x-agent-id", "alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(
        resp.status(),
        StatusCode::NOT_FOUND,
        "#1620: unknown pending id must be 404 on the sqlite branch"
    );
}

#[tokio::test]
async fn http_approve_pending_rejects_invalid_agent_id() {
    // Passing a malformed X-Agent-Id (containing a space) triggers
    // resolve_http_agent_id's validation and yields a 400.
    // S5-C1 (2026-05-13): /approve is HMAC-gated — sign so the
    // body reaches the agent-id validator (which is what we're
    // pinning here).
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state));
    let id = Uuid::new_v4().to_string();
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{id}/approve"))
                .method("POST")
                .header("x-agent-id", "bad agent")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_reject_pending_unknown_id_returns_404() {
    // S5-C1 (2026-05-13): /reject is HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
        .with_state(test_app_state(state));
    let unknown = Uuid::new_v4().to_string();
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &unknown, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{unknown}/reject"))
                .method("POST")
                .header("x-agent-id", "alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_reject_pending_rejects_invalid_agent_id() {
    // S5-C1 (2026-05-13): /reject is HMAC-gated; sign so the body
    // reaches the agent-id validator.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
        .with_state(test_app_state(state));
    let id = Uuid::new_v4().to_string();
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{id}/reject"))
                .method("POST")
                .header("x-agent-id", "bad agent")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- Search edge cases ----

#[tokio::test]
async fn http_search_rejects_blank_query() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/search",
            axum::routing::get(search_memories),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/search?q=%20%20%20") // whitespace only
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_search_long_query_succeeds() {
    // Boundary: very long query string. Must not crash; either
    // returns 200 with empty results or a specific 400 from validation.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/search",
            axum::routing::get(search_memories),
        )
        .with_state(test_app_state(state));
    let q = "a".repeat(2_000);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/search?q={q}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(
        resp.status() == StatusCode::OK
            || resp.status() == StatusCode::BAD_REQUEST
            || resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
        "unexpected status {}",
        resp.status()
    );
}

#[tokio::test]
async fn http_search_normal_query_returns_results_array() {
    // Sanity smoke for the search happy path post-validation. Empty
    // DB → 200 with results=[].
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/search",
            axum::routing::get(search_memories),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/search?q=hello")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["results"].is_array());
    assert_eq!(v["query"], "hello");
}

#[tokio::test]
async fn http_search_invalid_agent_id_filter_rejected() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/search",
            axum::routing::get(search_memories),
        )
        .with_state(test_app_state(state));
    // `bad agent` (decoded with %20 space) — agent_id must reject spaces.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/search?q=test&agent_id=bad%20agent")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- Recall edge cases ----

#[tokio::test]
async fn http_recall_get_rejects_blank_context() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/recall",
            axum::routing::get(recall_memories_get),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/recall?context=%20")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_recall_get_zero_budget_tokens_returns_empty() {
    // Phase P6 (R1): budget_tokens=0 is now a valid request — see
    // recall_post_zero_budget_tokens_returns_empty for full
    // semantics. Returns 200 with an empty memories array.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/recall",
            axum::routing::get(recall_memories_get),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/recall?context=hi&budget_tokens=0")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert_eq!(v["budget_tokens"], 0);
    assert_eq!(v["meta"]["budget_overflow"], false);
}

#[tokio::test]
async fn http_recall_post_rejects_blank_context() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/recall", axum_post(recall_memories_post))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"context": "   "});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/recall")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_recall_post_keyword_mode_returns_mode_field() {
    // Without an embedder, recall_response must fall through to
    // keyword mode and surface that fact on the response.
    let state = test_state();
    let _id = insert_test_memory(&state, "recall-mode", "the title").await;
    let app = Router::new()
        .route("/api/v1/memories/recall", axum_post(recall_memories_post))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"context": "title", "namespace": "recall-mode"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/recall")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["mode"], "keyword");
}

// ---- Sync / streaming-like paths ----

#[tokio::test]
async fn http_sync_since_empty_db_returns_zero_count() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/since", axum::routing::get(sync_since))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/since?since=2000-01-01T00:00:00Z&limit=10")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert!(v["earliest_updated_at"].is_null());
    assert!(v["latest_updated_at"].is_null());
}

#[tokio::test]
async fn http_sync_since_clamps_oversized_limit() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/since", axum::routing::get(sync_since))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/since?limit=999999")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // Limit must be clamped to <= 10_000.
    assert!(v["limit"].as_u64().unwrap() <= 10_000);
}

#[tokio::test]
async fn http_sync_since_empty_since_string_treated_as_full_snapshot() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // since="" must NOT be parsed as RFC 3339. The handler short-circuits
    // empty strings to "no since filter" and returns a full snapshot.
    let state = test_state();
    let _id = insert_test_memory(&state, "sync-empty", "row").await;
    let app = Router::new()
        .route("/api/v1/sync/since", axum::routing::get(sync_since))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/since?since=")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_sync_since_records_peer_via_observe() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // Hitting sync_since with a `peer=` param and an X-Agent-Id header
    // exercises the side-effect sync_state_observe write path.
    let state = test_state();
    let _id = insert_test_memory(&state, "sync-peer", "row").await;
    let app = Router::new()
        .route("/api/v1/sync/since", axum::routing::get(sync_since))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/since?peer=peer-x")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- Capabilities + session_start + taxonomy ----

#[tokio::test]
async fn http_capabilities_returns_features() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/capabilities", axum::routing::get(get_capabilities))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/capabilities")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // embedder_loaded must be false in this AppState — we wired
    // Arc::new(None).
    assert_eq!(v["features"]["embedder_loaded"], false);
}

#[tokio::test]
async fn http_session_start_rejects_invalid_agent_id() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/session/start", axum_post(session_start))
        .with_state(state);
    let body = serde_json::json!({"agent_id": "bad agent id with spaces"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/session/start")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_session_start_stamps_session_id() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/session/start", axum_post(session_start))
        .with_state(state);
    let body = serde_json::json!({});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/session/start")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["session_id"].as_str().is_some());
}

#[tokio::test]
async fn http_get_taxonomy_rejects_invalid_prefix() {
    // namespace validation rejects spaces — `bad%20prefix` decodes
    // to `bad prefix`, which fails validate_namespace.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/taxonomy?prefix=bad%20prefix")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_get_taxonomy_clamps_depth_and_limit() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/taxonomy?depth=1000&limit=999999")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- list_subscriptions ----

#[tokio::test]
async fn http_list_subscriptions_empty_returns_zero() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/subscriptions",
            axum::routing::get(list_subscriptions),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert!(v["subscriptions"].as_array().unwrap().is_empty());
}

#[tokio::test]
async fn http_list_subscriptions_filters_by_agent_id() {
    // #872 / #874 (security-high/medium, 2026-05-18) — listing is
    // owner-scoped via the authenticated X-Agent-Id header; the
    // `?agent_id=` query parameter must match the header or 403.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/subscriptions",
            axum::routing::get(list_subscriptions),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions?agent_id=alice")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- get_inbox ----

#[tokio::test]
async fn http_get_inbox_with_x_agent_id_header() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/inbox", axum::routing::get(get_inbox))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/inbox?unread_only=true&limit=20")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// -------------------------------------------------------------------
// Wave 3 (Closer T) — targeted unit tests for code paths NOT yet
// covered by Wave 2's smoke + lifecycle + format tests. Each block
// below targets a specific uncovered run located via the pre-coverage
// JSON snapshot. These exercise production code paths in-process
// (federation = None, embedder = None) so the federation-quorum
// branches stay short-circuited and only the local logic under test
// executes.
// -------------------------------------------------------------------

// ---- check_duplicate (handlers.rs ~L1930-2026) ----

#[tokio::test]
async fn http_check_duplicate_rejects_invalid_title() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/check_duplicate", axum_post(check_duplicate))
        .with_state(test_app_state(state));
    // Empty title fails validation.
    let body = serde_json::json!({"title": "", "content": "non-empty"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/check_duplicate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_check_duplicate_rejects_invalid_content() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/check_duplicate", axum_post(check_duplicate))
        .with_state(test_app_state(state));
    // Empty content fails validation.
    let body = serde_json::json!({"title": "ok", "content": ""});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/check_duplicate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_check_duplicate_rejects_invalid_namespace() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/check_duplicate", axum_post(check_duplicate))
        .with_state(test_app_state(state));
    // Namespace with disallowed characters fails validation.
    let body = serde_json::json!({
        "title": "ok",
        "content": "ok content",
        "namespace": "BAD NAMESPACE WITH SPACES",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/check_duplicate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_check_duplicate_503_when_no_embedder() {
    // Without an embedder, check_duplicate cannot run (returns 503).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/check_duplicate", axum_post(check_duplicate))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"title": "anchor", "content": "some long enough content"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/check_duplicate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

// ---- entity_register / entity_get_by_alias (handlers.rs ~L2058-2205) ----

#[tokio::test]
async fn http_entity_register_creates_then_idempotent_returns_200() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/entities", axum_post(entity_register))
        .with_state(test_app_state(state.clone()));
    // First call: 201 CREATED.
    let body = serde_json::json!({
        "canonical_name": "Acme Corp",
        "namespace": "kg-test",
        "aliases": ["acme", "Acme"],
        "metadata": {"region": "us"},
    });
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    // Second call with same canonical_name+namespace: 200 OK + created=false.
    let resp2 = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp2.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_entity_register_rejects_invalid_canonical_name() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/entities", axum_post(entity_register))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "canonical_name": "",
        "namespace": "kg-test",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_entity_register_rejects_invalid_namespace() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/entities", axum_post(entity_register))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "canonical_name": "Acme",
        "namespace": "BAD NS!",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_entity_register_rejects_invalid_agent_id_header() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/entities", axum_post(entity_register))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "canonical_name": "Acme",
        "namespace": "kg-test",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "BAD AGENT!")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_entity_register_collision_with_non_entity_returns_409() {
    // Pre-seed a non-entity memory at (namespace, title), then attempt
    // entity_register with the same canonical_name+namespace.
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    {
        let lock = state.lock().await;
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "collide-ns".into(),
            title: "Acme Squat".into(),
            content: "this is a regular memory".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/entities", axum_post(entity_register))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "canonical_name": "Acme Squat",
        "namespace": "collide-ns",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn http_entity_get_by_alias_blank_alias_rejected() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/entities/by_alias",
            axum::routing::get(entity_get_by_alias),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities/by_alias?alias=%20%20")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_entity_get_by_alias_invalid_namespace_rejected() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/entities/by_alias",
            axum::routing::get(entity_get_by_alias),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities/by_alias?alias=acme&namespace=BAD%20NS!")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_entity_get_by_alias_returns_found_false_when_unknown() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/entities/by_alias",
            axum::routing::get(entity_get_by_alias),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities/by_alias?alias=nonexistent")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["found"], serde_json::json!(false));
}

#[tokio::test]
async fn http_entity_get_by_alias_returns_found_true_after_register() {
    // Pre-register an entity, then look it up by alias.
    let state = test_state();
    {
        let lock = state.lock().await;
        db::entity_register(
            &lock.0,
            "Acme Corp",
            "kg-lookup",
            &["acme".to_string(), "ACME".to_string()],
            &serde_json::json!({}),
            Some("alice"),
        )
        .unwrap();
    }
    let app = Router::new()
        .route(
            "/api/v1/entities/by_alias",
            axum::routing::get(entity_get_by_alias),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities/by_alias?alias=acme&namespace=kg-lookup")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["found"], serde_json::json!(true));
    assert_eq!(v["canonical_name"], serde_json::json!("Acme Corp"));
}

// ---- kg_timeline (handlers.rs ~L2219-2284) ----

#[tokio::test]
async fn http_kg_timeline_rejects_invalid_source_id() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
        .with_state(test_app_state(state.clone()));
    // Empty source_id is rejected by validate_id.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/timeline?source_id=")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_timeline_rejects_invalid_since() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
        .with_state(test_app_state(state.clone()));
    let id = Uuid::new_v4().to_string();
    let uri = format!("/api/v1/kg/timeline?source_id={id}&since=NOT-A-TIMESTAMP");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(&uri)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_timeline_rejects_invalid_until() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
        .with_state(test_app_state(state.clone()));
    let id = Uuid::new_v4().to_string();
    let uri = format!("/api/v1/kg/timeline?source_id={id}&until=garbage");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(&uri)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_timeline_returns_empty_for_unlinked_source() {
    // Valid source_id with no outbound links → 200 + count=0.
    let state = test_state();
    let id = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "kg-tl".into(),
            title: "anchor".into(),
            content: "anchor body".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };
    let app = Router::new()
        .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
        .with_state(test_app_state(state.clone()));
    let uri = format!("/api/v1/kg/timeline?source_id={id}");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(&uri)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], serde_json::json!(0));
    assert!(v["events"].is_array());
}

// ---- kg_invalidate (handlers.rs ~L2300-2365) ----

#[tokio::test]
async fn http_kg_invalidate_rejects_invalid_link() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
        .with_state(test_app_state(state.clone()));
    // Self-link: source_id == target_id → validate_link rejects.
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "target_id": "11111111-1111-4111-8111-111111111111",
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/invalidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_invalidate_rejects_invalid_valid_until() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "target_id": "22222222-2222-4222-8222-222222222222",
        "relation": "related_to",
        "valid_until": "garbage",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/invalidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    // Bad valid_until is the second validation gate; the (UUID, UUID,
    // related_to) link itself is well-formed.
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_invalidate_404_when_link_missing() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "target_id": "22222222-2222-4222-8222-222222222222",
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/invalidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_kg_invalidate_marks_link_as_invalidated() {
    // Pre-seed two memories + an outbound link, then invalidate.
    let state = test_state();
    let (a_id, b_id) = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mk = |title: &str| Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "kg-inv".into(),
            title: title.into(),
            content: format!("{title} body"),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let a = db::insert(&lock.0, &mk("source-a")).unwrap();
        let b = db::insert(&lock.0, &mk("target-b")).unwrap();
        db::create_link(&lock.0, &a, &b, "related_to").unwrap();
        (a, b)
    };
    let app = Router::new()
        .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": a_id,
        "target_id": b_id,
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/invalidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["found"], serde_json::json!(true));
}

// ---- kg_query (handlers.rs ~L2387-2484) ----

#[tokio::test]
async fn http_kg_query_rejects_invalid_source_id() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    // Empty source_id is rejected by validate_id.
    let body = serde_json::json!({"source_id": ""});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_query_rejects_invalid_valid_at() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "valid_at": "not-a-timestamp",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_query_rejects_invalid_allowed_agent() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "allowed_agents": ["BAD AGENT!"],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_kg_query_returns_422_for_oversized_max_depth() {
    // The DB layer rejects max_depth > supported with an error whose
    // message contains "max_depth"; the handler must return 422.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "max_depth": 999_usize,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[tokio::test]
async fn http_kg_query_returns_422_for_zero_max_depth() {
    // The DB layer rejects max_depth=0 with "max_depth must be >= 1";
    // handler routes that to 422.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "max_depth": 0_usize,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[tokio::test]
async fn http_kg_query_returns_empty_for_unlinked_source() {
    // Real source memory but no links → 200 with count=0.
    let state = test_state();
    let id = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "kg-q".into(),
            title: "anchor".into(),
            content: "anchor body".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": id,
        "max_depth": 1_usize,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], serde_json::json!(0));
    assert_eq!(v["max_depth"], serde_json::json!(1));
}

#[tokio::test]
async fn http_kg_query_short_circuits_empty_allowed_agents() {
    // Empty allowed_agents → DB layer short-circuits with empty result.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "allowed_agents": [],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], serde_json::json!(0));
}

// ---- delete_link / get_links / forget_memories / list_namespaces ----

#[tokio::test]
async fn http_delete_link_rejects_self_link() {
    // delete_link reuses validate_link → self-link rejected with 400.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/links", axum::routing::delete(delete_link))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "target_id": "11111111-1111-4111-8111-111111111111",
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("DELETE")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_delete_link_returns_deleted_false_when_missing() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/links", axum::routing::delete(delete_link))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "source_id": "11111111-1111-4111-8111-111111111111",
        "target_id": "22222222-2222-4222-8222-222222222222",
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("DELETE")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["deleted"], serde_json::json!(false));
}

#[tokio::test]
async fn http_get_links_for_unknown_id_returns_empty_array() {
    // Unknown ID (well-formed but no row) → 200 OK + empty links.
    // validate_id only rejects empty/oversized/control-char strings,
    // so an unrecognised but well-formed id still reaches the DB layer.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/nonexistent-id/links")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["links"].is_array());
    assert_eq!(v["links"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn http_get_links_returns_empty_array_for_unlinked_id() {
    let state = test_state();
    let id = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "links-test".into(),
            title: "anchor".into(),
            content: "no links yet".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };
    let app = Router::new()
        .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}/links"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["links"].is_array());
    assert_eq!(v["links"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn http_list_namespaces_returns_empty_for_fresh_db() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/namespaces", axum::routing::get(list_namespaces))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["namespaces"].is_array());
}

#[tokio::test]
async fn http_forget_memories_with_namespace_filter_returns_count() {
    // Pre-seed two rows in a target namespace, then POST forget.
    let state = test_state();
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        for i in 0..3 {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Long,
                namespace: "forget-target".into(),
                title: format!("row-{i}"),
                content: format!("content {i}"),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "test".into(),
                access_count: 0,
                created_at: now.clone(),
                updated_at: now.clone(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"namespace": "forget-target"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // count of deleted rows is reported under "deleted"
    assert!(v["deleted"].as_u64().is_some());
}

// ---- archive_stats / archive_by_ids zero-id batch ----

#[tokio::test]
async fn http_archive_stats_empty_db_returns_zero() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/stats")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_purge_archive_returns_zero_for_empty_archive() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/purge", axum_post(purge_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/purge")
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["purged"], serde_json::json!(0));
}

// ---- run_gc / export_memories / import_memories ----

#[tokio::test]
async fn http_run_gc_returns_zero_for_clean_db() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/gc", axum_post(run_gc))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/gc")
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_export_memories_empty_returns_zero_count() {
    // #957 (security-critical, 2026-05-20) — `/api/v1/export` is now
    // admin-gated. The empty-corpus happy path still needs to fire so
    // the test passes an admin caller via `test_app_state_with_admin`
    // and the matching `X-Agent-Id` header. The non-admin rejection
    // surface is covered by `tests/export_memories_admin_gate_957.rs`.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/export", axum::routing::get(export_memories))
        .with_state(test_app_state_with_admin(state, "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/export")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], serde_json::json!(0));
}

#[tokio::test]
async fn http_import_memories_oversized_batch_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/import", axum_post(import_memories))
        .with_state(test_app_state(state));
    // MAX_BULK_SIZE+1 stub rows. We use minimal Memory payloads so
    // serialisation is cheap.
    let many: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
        .map(|i| {
            serde_json::json!({
                "id": format!("11111111-1111-4111-8111-{:012}", i),
                "tier": Tier::Long.as_str(),
                "namespace": "imp",
                "title": format!("t-{i}"),
                "content": "x",
                "tags": [],
                "priority": 5,
                "confidence": 1.0,
                "source": "import",
                "access_count": 0,
                "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-01-01T00:00:00Z",
                "last_accessed_at": null,
                "expires_at": null,
                "metadata": {},
            })
        })
        .collect();
    let body = serde_json::json!({"memories": many});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/import")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_import_memories_skips_invalid_rows() {
    // One valid + one invalid (missing required fields) → 200 with errors.
    // #956 (security-medium, 2026-05-20) — `/api/v1/import` is now
    // admin-gated; route through `test_app_state_with_admin` + matching
    // `X-Agent-Id` header. Non-admin rejection surface covered by
    // `tests/import_memories_admin_gate_956.rs`.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/import", axum_post(import_memories))
        .with_state(test_app_state_with_admin(state, "ops:admin"));
    let valid = serde_json::json!({
        "id": Uuid::new_v4().to_string(),
        "tier": Tier::Long.as_str(),
        "namespace": "imp",
        "title": "ok-row",
        "content": "valid content",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "import",
        "access_count": 0,
        "created_at": "2026-01-01T00:00:00Z",
        "updated_at": "2026-01-01T00:00:00Z",
        "last_accessed_at": null,
        "expires_at": null,
        "metadata": {},
    });
    // Empty title is rejected by validate_memory.
    let invalid = serde_json::json!({
        "id": Uuid::new_v4().to_string(),
        "tier": Tier::Long.as_str(),
        "namespace": "imp",
        "title": "",
        "content": "x",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "import",
        "access_count": 0,
        "created_at": "2026-01-01T00:00:00Z",
        "updated_at": "2026-01-01T00:00:00Z",
        "last_accessed_at": null,
        "expires_at": null,
        "metadata": {},
    });
    let body = serde_json::json!({"memories": [valid, invalid]});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/import")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ops:admin")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // Valid row imported = 1; errors array contains the invalid row.
    assert_eq!(v["imported"], serde_json::json!(1));
    assert!(v["errors"].as_array().unwrap().len() >= 1);
}

// ---- get_stats / get_taxonomy / sync_push pending+meta paths ----

#[tokio::test]
async fn http_get_stats_empty_db() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/stats", axum::routing::get(get_stats))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/stats")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_sync_push_namespace_meta_clears_garbage_skipped() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // namespace_meta_clears with a malformed namespace must be skipped
    // (not crash, not cleared).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "peer-x",
        "memories": [],
        "namespace_meta_clears": ["BAD NAMESPACE!"],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_sync_push_pending_decision_invalid_id_skipped() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // pending_decisions with an invalid id must be skipped (not crash).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "peer-x",
        "memories": [],
        "pending_decisions": [
            {"id": "BAD ID!", "approved": true, "decider": "alice"}
        ],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_sync_push_namespace_meta_invalid_skipped() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // namespace_meta with an invalid namespace OR invalid standard_id
    // should be skipped (incremented under skipped, not applied).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "peer-x",
        "memories": [],
        "namespace_meta": [
            {"namespace": "BAD NS!", "standard_id": "11111111-1111-4111-8111-111111111111", "parent_namespace": null}
        ],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_sync_push_dry_run_namespace_meta_no_apply() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    // dry_run: namespace_meta entries are counted as noop, not applied.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "sender_agent_id": "peer-x",
        "memories": [],
        "dry_run": true,
        "namespace_meta_clears": ["preview-ns"],
        "pending_decisions": [
            {"id": "11111111-1111-4111-8111-111111111111", "approved": true, "decider": "alice"}
        ],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ----------------------------------------------------------------
// W8 / H8a — archive lane sweep. ~30 tests covering the 6 archive
// handlers (list_archive, archive_by_ids, purge_archive,
// restore_archive, archive_stats, forget_memories) past the
// existing happy-path and validation suites. Reuses
// `test_state`, `test_app_state`, and `insert_test_memory`.
// ----------------------------------------------------------------

// ---- list_archive (5 new) ----

#[tokio::test]
async fn http_list_archive_empty_returns_empty_array() {
    // Cold DB: response shape is `{archived: [], count: 0}` with 200.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert_eq!(v["archived"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn http_list_archive_with_items_returns_them() {
    // Two archived rows must appear in the listing.
    let state = test_state();
    let id_a = insert_test_memory(&state, "h8a-list-items", "row-a").await;
    let id_b = insert_test_memory(&state, "h8a-list-items", "row-b").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id_a, Some("test")).unwrap();
        db::archive_memory(&lock.0, &id_b, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?limit=10")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 2);
}

#[tokio::test]
async fn http_list_archive_pagination_offset_skips() {
    // Insert+archive 3 rows; limit=1&offset=1 returns 1 row (the
    // middle one by archived_at DESC ordering).
    let state = test_state();
    let id1 = insert_test_memory(&state, "h8a-page", "row-1").await;
    let id2 = insert_test_memory(&state, "h8a-page", "row-2").await;
    let id3 = insert_test_memory(&state, "h8a-page", "row-3").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id1, Some("p")).unwrap();
        db::archive_memory(&lock.0, &id2, Some("p")).unwrap();
        db::archive_memory(&lock.0, &id3, Some("p")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?limit=1&offset=1")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
}

#[tokio::test]
async fn http_list_archive_namespace_filter_excludes_others() {
    // Archive rows in two namespaces; filtering by one returns
    // only that namespace's rows.
    let state = test_state();
    let id_a = insert_test_memory(&state, "h8a-ns-a", "row-a").await;
    let id_b = insert_test_memory(&state, "h8a-ns-b", "row-b").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
        db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?namespace=h8a-ns-a&limit=10")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
    let entries = v["archived"].as_array().unwrap();
    assert_eq!(entries[0]["namespace"], "h8a-ns-a");
}

#[tokio::test]
async fn http_list_archive_namespace_filter_unknown_returns_empty() {
    // Filtering by a namespace with nothing archived yields count=0
    // and an empty array (not 404).
    let state = test_state();
    let id_a = insert_test_memory(&state, "h8a-ns-known", "row-a").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?namespace=h8a-no-such-ns")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
}

// ---- archive_by_ids (5 new) ----

#[tokio::test]
async fn http_archive_by_ids_single_id_success() {
    // One id, no fanout — happy path returns 200 with archived=[id].
    let state = test_state();
    let id = insert_test_memory(&state, "h8a-aby-single", "row").await;
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({"ids": [id], "reason": "h8a-single"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
    assert_eq!(v["missing"].as_array().unwrap().len(), 0);
    assert_eq!(v["reason"], "h8a-single");
}

#[tokio::test]
async fn http_archive_by_ids_bulk_success() {
    // Three live ids in one request — all archived, none missing.
    let state = test_state();
    let id1 = insert_test_memory(&state, "h8a-bulk", "row-1").await;
    let id2 = insert_test_memory(&state, "h8a-bulk", "row-2").await;
    let id3 = insert_test_memory(&state, "h8a-bulk", "row-3").await;
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({"ids": [id1, id2, id3]});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 3);
    assert_eq!(v["missing"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn http_archive_by_ids_empty_array_returns_ok_zero_count() {
    // Empty `ids` array is not an error — returns 200 with zero
    // archived and zero missing. (No batch-size violation, no rows.)
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({"ids": []});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert_eq!(v["archived"].as_array().unwrap().len(), 0);
    assert_eq!(v["missing"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn http_archive_by_ids_missing_ids_field_returns_400() {
    // Missing required `ids` field → 400 (axum Json extractor rejects
    // body that doesn't deserialize).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"reason": "no-ids-field"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(resp.status().is_client_error());
}

#[tokio::test]
async fn http_archive_by_ids_malformed_json_returns_400() {
    // Garbage bytes for the body → 400.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from("not-valid-json{{"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(resp.status().is_client_error());
}

// ---- purge_archive (4 new) ----

#[tokio::test]
async fn http_purge_archive_older_than_keeps_recent() {
    // older_than_days=365 against archived rows whose archived_at is
    // "now" must purge zero rows (none are older than a year).
    let state = test_state();
    let id = insert_test_memory(&state, "h8a-purge-recent", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("recent")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::delete(purge_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?older_than_days=365")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["purged"], 0);
    // Row still in archive.
    let lock = state.lock().await;
    let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
    assert_eq!(rows.len(), 1);
}

#[tokio::test]
async fn http_purge_archive_unfiltered_purges_everything() {
    // No `older_than_days` query → purge all archived rows.
    //
    // #936 (security-critical, 2026-05-20) — uses the admin path
    // (X-Agent-Id matches `admin_agent_ids`) so the destructive
    // cross-tenant wipe still runs. The non-admin owner-scoped
    // restriction is covered by `tests/archive_purge_owner_gate_936.rs`.
    let state = test_state();
    for i in 0..3 {
        let id = insert_test_memory(&state, "h8a-purge-all", &format!("row-{i}")).await;
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("all")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::delete(purge_archive))
        .with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("DELETE")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["purged"], 3);
    let lock = state.lock().await;
    let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
    assert!(rows.is_empty());
}

#[tokio::test]
async fn http_purge_archive_zero_days_purges_all_archived() {
    // older_than_days=0 → cutoff is "now", so every archived row is
    // older than the cutoff and gets purged.
    //
    // #936 (security-critical, 2026-05-20) — uses the admin path so
    // the cross-tenant wipe still runs. Owner-scoped semantics covered
    // by `tests/archive_purge_owner_gate_936.rs`.
    let state = test_state();
    let id = insert_test_memory(&state, "h8a-purge-zero", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("zero")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::delete(purge_archive))
        .with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?older_than_days=0")
                .method("DELETE")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // count of purged rows ≥ 1 (the recent archive is older than `now`).
    assert!(v["purged"].as_u64().unwrap() >= 1);
}

#[tokio::test]
async fn http_purge_archive_response_shape_has_purged_key() {
    // Smoke: response is a JSON object with a numeric "purged" key
    // even when the archive is empty.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::delete(purge_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v.is_object());
    assert!(v["purged"].is_number());
}

// ---- restore_archive (5 new) ----

#[tokio::test]
async fn http_restore_archive_happy_path_and_listed_in_active() {
    // Archive then restore: response has restored=true, the row
    // is gone from the archive, and is present in the active table.
    let state = test_state();
    let id = insert_test_memory(&state, "h8a-restore-ok", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["restored"], true);
    assert_eq!(v["id"], id);
    // Active row exists; archive entry is gone.
    let lock = state.lock().await;
    let got = db::get(&lock.0, &id).unwrap();
    assert!(got.is_some());
    let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
    assert!(archived.is_empty());
}

#[tokio::test]
async fn http_restore_archive_then_list_archive_excludes_restored() {
    // After a restore, GET /api/v1/archive doesn't return the row
    // (the archive table no longer holds it).
    let state = test_state();
    let id = insert_test_memory(&state, "h8a-restore-list", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
        // Sanity: archive contains 1.
        let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
        assert_eq!(rows.len(), 1);
    }
    let restore_app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state.clone()));
    let resp = restore_app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let list_app = Router::new()
        .route("/api/v1/archive", axum::routing::get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = list_app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
}

#[tokio::test]
async fn http_restore_archive_preserves_namespace_and_title() {
    // Restored row keeps its original namespace/title (the data is
    // copied verbatim back to `memories`).
    let state = test_state();
    let id = insert_test_memory(&state, "h8a-rest-meta", "preserve-me").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let lock = state.lock().await;
    let got = db::get(&lock.0, &id).unwrap().unwrap();
    assert_eq!(got.namespace, "h8a-rest-meta");
    assert_eq!(got.title, "preserve-me");
}

#[tokio::test]
async fn http_restore_archive_after_purge_returns_404() {
    // Archive → purge → restore: the row is gone from the archive
    // table so restore returns 404.
    let state = test_state();
    let id = insert_test_memory(&state, "h8a-rest-purged", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
        // Purge unconditionally.
        db::purge_archive(&lock.0, None).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_restore_archive_oversized_id_returns_400() {
    // An id longer than MAX_ID_LEN (128) is rejected by
    // validate::validate_id with 400, not handed off to the DB.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state));
    let huge = "a".repeat(200);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{huge}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- archive_stats (3 new) ----

#[tokio::test]
async fn http_archive_stats_with_data_reports_total_and_breakdown() {
    // Two archived rows under one namespace, one under another →
    // archived_total=3, by_namespace lists both.
    let state = test_state();
    let id_a1 = insert_test_memory(&state, "h8a-stats-a", "row-1").await;
    let id_a2 = insert_test_memory(&state, "h8a-stats-a", "row-2").await;
    let id_b1 = insert_test_memory(&state, "h8a-stats-b", "row-3").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id_a1, Some("t")).unwrap();
        db::archive_memory(&lock.0, &id_a2, Some("t")).unwrap();
        db::archive_memory(&lock.0, &id_b1, Some("t")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/stats")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["archived_total"], 3);
    let by_ns = v["by_namespace"].as_array().unwrap();
    assert_eq!(by_ns.len(), 2);
    // First entry has the highest count (DESC). ns-a has 2, ns-b has 1.
    assert_eq!(by_ns[0]["count"], 2);
    assert_eq!(by_ns[0]["namespace"], "h8a-stats-a");
}

#[tokio::test]
async fn http_archive_stats_empty_returns_total_zero_empty_breakdown() {
    // Cold DB: archived_total=0, by_namespace=[].
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/stats")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["archived_total"], 0);
    assert!(v["by_namespace"].as_array().unwrap().is_empty());
}

#[tokio::test]
async fn http_archive_stats_unaffected_by_active_rows() {
    // Active (non-archived) rows must not appear in archive stats —
    // archived_total only counts the `archived_memories` table.
    let state = test_state();
    // Five active rows, none archived.
    for i in 0..5 {
        insert_test_memory(&state, "h8a-stats-active", &format!("row-{i}")).await;
    }
    let app = Router::new()
        .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive/stats")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["archived_total"], 0);
}

// ---- forget_memories (6 new) ----

#[tokio::test]
async fn http_forget_memories_no_filter_returns_400() {
    // db::forget bails with "at least one of namespace, pattern, or
    // tier is required" when all filters are absent — the handler
    // surfaces this as 400.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_forget_memories_pattern_only_deletes_matches() {
    // FTS pattern "delete-me" must match exactly the rows whose
    // content contains it.
    let state = test_state();
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        for (i, content) in ["delete-me alpha", "keep-this beta", "delete-me gamma"]
            .iter()
            .enumerate()
        {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Long,
                namespace: "h8a-forget-pat".into(),
                title: format!("row-{i}"),
                content: (*content).into(),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "test".into(),
                access_count: 0,
                created_at: now.clone(),
                updated_at: now.clone(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"pattern": "delete-me"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // 2 rows had the pattern "delete-me".
    assert_eq!(v["deleted"], 2);
}

#[tokio::test]
async fn http_forget_memories_by_tier_only_targets_tier() {
    // Mix of Short/Long rows, tier=short forgets only the Short rows.
    let state = test_state();
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        for (i, tier) in [Tier::Short, Tier::Short, Tier::Long].iter().enumerate() {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: tier.clone(),
                namespace: "h8a-forget-tier".into(),
                title: format!("row-{i}"),
                content: format!("content {i}"),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "test".into(),
                access_count: 0,
                created_at: now.clone(),
                updated_at: now.clone(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"tier": Tier::Short.as_str()});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["deleted"], 2);
}

#[tokio::test]
async fn http_forget_memories_combined_filters_intersect() {
    // namespace + pattern should AND — only rows in `target-ns`
    // matching `purge` are forgotten.
    let state = test_state();
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        // 2 in target ns matching the pattern, 1 in target ns not
        // matching, 1 in another ns matching.
        for (ns, content) in [
            ("h8a-forget-and", "purge alpha"),
            ("h8a-forget-and", "purge beta"),
            ("h8a-forget-and", "keep gamma"),
            ("h8a-forget-other", "purge delta"),
        ] {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Long,
                namespace: ns.into(),
                title: format!("row-{content}"),
                content: content.into(),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "test".into(),
                access_count: 0,
                created_at: now.clone(),
                updated_at: now.clone(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "namespace": "h8a-forget-and",
        "pattern": "purge"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // 2 rows in target ns matched the pattern.
    assert_eq!(v["deleted"], 2);
}

#[tokio::test]
async fn http_forget_memories_malformed_json_returns_400() {
    // Garbage body → 400 (Json extractor rejects).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from("{not-json"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(resp.status().is_client_error());
}

#[tokio::test]
async fn http_forget_memories_no_match_returns_zero_deleted() {
    // namespace filter that matches nothing → 200 with deleted=0.
    let state = test_state();
    // Seed a few rows in a *different* namespace so the table isn't
    // wholly empty (forget shouldn't touch them).
    for i in 0..3 {
        insert_test_memory(&state, "h8a-forget-keep", &format!("k-{i}")).await;
    }
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({"namespace": "h8a-forget-empty"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["deleted"], 0);
    // The keep namespace still has 3 rows.
    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("h8a-forget-keep"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert_eq!(rows.len(), 3);
}
// -------------------------------------------------------------------
// Wave 8 (Closer H8b) — handlers.rs inbox/subscriptions lane.
//
// Targets the six handler entry points that drive S32/S33/S36:
//   - subscribe / unsubscribe / list_subscriptions
//   - notify / get_inbox
//   - session_start
//
// All tests run in-process against a `:memory:` DB with `federation =
// None` so the quorum branches stay short-circuited. We exercise the
// happy path *and* the validation/error edges — the latter is where
// pre-W8 coverage was thin (~81% on handlers.rs).
// -------------------------------------------------------------------

// ---- subscribe (POST /api/v1/subscriptions) ----

/// Happy path: a valid `https://` webhook URL produces a 201 with the
/// canonical webhook-shape echo (`id`, `url`, `events`, `created_by`).
#[tokio::test]
async fn h8b_subscribe_https_url_returns_created() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "url": "https://example.com/webhook",
        "events": "*",
        "secret": "h8b-test-secret",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["id"].as_str().is_some(), "id must be returned");
    assert_eq!(v["url"], "https://example.com/webhook");
    assert_eq!(v["created_by"], "alice");
}

/// Body without `url` *or* `namespace` is rejected with 400 — the
/// handler short-circuits before touching the DB.
#[tokio::test]
async fn h8b_subscribe_missing_url_and_namespace_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    // R3-S1.HMAC (2026-05-13): supply secret so this test pins the
    // url-or-namespace branch (not the HMAC branch).
    let body = serde_json::json!({"events": "*", "secret": "h8b-test-secret"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("url or namespace"),);
}

/// A URL missing the scheme is invalid (`validate_url` reports "missing
/// scheme"). Handler must surface this as 400.
#[tokio::test]
async fn h8b_subscribe_invalid_url_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "url": "not-a-url",
        "events": "*",
        "secret": "h8b-test-secret",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

/// SSRF guard: explicit loopback (127.0.0.1) is permitted (matches the
/// `is_loopback()` allowance in `validate_url`); but a metadata-service
/// IP (169.254.169.254 — link-local) must be rejected. Both cases share
/// the same handler entry-point so we exercise them together.
#[tokio::test]
async fn h8b_subscribe_rejects_link_local_metadata_ip() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "url": "https://169.254.169.254/latest/meta-data/",
        "events": "*",
        "secret": "h8b-test-secret",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let err = v["error"].as_str().unwrap();
    // The validator rejects with either "private", "link-local", or
    // similar wording — accept any of the SSRF-guard messages.
    assert!(
        err.contains("private") || err.contains("link-local") || err.contains("non-loopback"),
        "expected SSRF rejection, got: {err}",
    );
}

/// S33 namespace-shape: when only `namespace` is supplied the handler
/// synthesizes a loopback URL and persists `namespace_filter`.
#[tokio::test]
async fn h8b_subscribe_namespace_shape_synthesizes_url() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "agent_id": "alice",
        "namespace": "team/research",
        "secret": "h8b-test-secret",
    });
    // #901: matching X-Agent-Id required for body.agent_id.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["agent_id"], "alice");
    assert_eq!(v["namespace"], "team/research");
    assert!(
        v["url"]
            .as_str()
            .unwrap()
            .starts_with("http://localhost/_ns/"),
        "expected synthetic URL, got {}",
        v["url"],
    );
}

/// Webhook body with explicit `events` filter ("memory.created") is
/// accepted and round-tripped back in the response.
#[tokio::test]
async fn h8b_subscribe_event_filter_round_trips() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "url": "https://example.com/hook",
        "events": "memory.created",
        "namespace_filter": "global",
        "secret": "h8b-test-secret",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["events"], "memory.created");
    assert_eq!(v["namespace_filter"], "global");
}

/// HMAC support: `secret` is accepted by the handler. Subscriptions
/// persist the hashed secret so the dispatcher can sign outbound posts.
/// We assert the create call succeeds — the secret must not leak back
/// in the response payload (the handler echoes only id/url/events/etc).
#[tokio::test]
async fn h8b_subscribe_persists_hmac_secret() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum_post(subscribe))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "url": "https://example.com/signed-hook",
        "events": "*",
        "secret": "topsecret-hmac-key",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // Secret must not be echoed in the response.
    assert!(v.get("secret").is_none(), "secret leaked into response");
    // The row exists in the DB.
    let lock = state.lock().await;
    let subs = crate::subscriptions::list(&lock.0, None).unwrap();
    assert_eq!(subs.len(), 1);
    assert_eq!(subs[0].url, "https://example.com/signed-hook");
}

// ---- unsubscribe (DELETE /api/v1/subscriptions) ----

/// Happy path: insert a subscription then delete by id; handler returns
/// `removed: true` and the row is gone from the listing.
#[tokio::test]
async fn h8b_unsubscribe_by_id_happy_path() {
    let state = test_state();
    let id = {
        let lock = state.lock().await;
        crate::subscriptions::insert(
            &lock.0,
            &crate::subscriptions::NewSubscription {
                url: "https://example.com/h",
                events: "*",
                secret: None,
                namespace_filter: None,
                agent_filter: None,
                created_by: Some("alice"),
                event_types: None,
            },
        )
        .unwrap()
    };

    let app = Router::new()
        .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
        .with_state(test_app_state(state.clone()));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/subscriptions?id={id}"))
                .method("DELETE")
                // #870 (security-high) — DELETE is owner-scoped; the
                // authenticated caller must match `created_by`.
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["removed"], true);

    // List must be empty afterwards.
    let lock = state.lock().await;
    assert!(
        crate::subscriptions::list(&lock.0, None)
            .unwrap()
            .is_empty()
    );
}

/// Deleting a nonexistent id returns 200 with `removed: false` — the
/// SQL `DELETE` is idempotent and the handler reports the outcome.
#[tokio::test]
async fn h8b_unsubscribe_nonexistent_id_returns_removed_false() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions?id=does-not-exist")
                .method("DELETE")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["removed"], false);
}

/// S33 (agent_id, namespace) shape — handler finds the row by filter
/// and deletes it without needing an explicit id.
#[tokio::test]
async fn h8b_unsubscribe_by_agent_and_namespace() {
    let state = test_state();
    // Seed a subscription owned by alice for namespace "demo".
    {
        let lock = state.lock().await;
        crate::subscriptions::insert(
            &lock.0,
            &crate::subscriptions::NewSubscription {
                url: "http://localhost/_ns/alice/demo",
                events: "*",
                secret: None,
                namespace_filter: Some("demo"),
                agent_filter: Some("alice"),
                created_by: Some("alice"),
                event_types: None,
            },
        )
        .unwrap();
    }

    let app = Router::new()
        .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
        .with_state(test_app_state(state.clone()));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions?namespace=demo")
                .method("DELETE")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["removed"], true);
}

/// Neither `id` nor (`agent_id`, `namespace`) is supplied — must 400.
#[tokio::test]
async fn h8b_unsubscribe_missing_id_and_namespace_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .method("DELETE")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"]
            .as_str()
            .unwrap()
            .contains("id or (agent_id, namespace)"),
    );
}

// ---- list_subscriptions (GET /api/v1/subscriptions) ----

/// With seeded data the handler returns rows shaped as the JSON spec
/// (top-level `namespace` field, alongside `namespace_filter`). Post
/// #872 the listing is always scoped to the authenticated caller — the
/// fixture seeds both rows under the same owner that the request
/// authenticates as so the assertion still covers the projection shape.
#[tokio::test]
async fn h8b_list_subscriptions_returns_seeded_rows() {
    let state = test_state();
    {
        let lock = state.lock().await;
        crate::subscriptions::insert(
            &lock.0,
            &crate::subscriptions::NewSubscription {
                url: "https://example.com/a",
                events: "*",
                secret: None,
                namespace_filter: Some("ns1"),
                agent_filter: Some("alice"),
                created_by: Some("alice"),
                event_types: None,
            },
        )
        .unwrap();
        crate::subscriptions::insert(
            &lock.0,
            &crate::subscriptions::NewSubscription {
                url: "https://example.com/b",
                events: "memory.updated",
                secret: None,
                namespace_filter: Some("ns2"),
                agent_filter: Some("alice"),
                created_by: Some("alice"),
                event_types: None,
            },
        )
        .unwrap();
    }

    let app = Router::new()
        .route(
            "/api/v1/subscriptions",
            axum::routing::get(list_subscriptions),
        )
        .with_state(test_app_state(state.clone()));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 2);
    let subs = v["subscriptions"].as_array().unwrap();
    assert_eq!(subs.len(), 2);
    // Each row has the expected `namespace` projection.
    for s in subs {
        assert!(s["namespace"].is_string());
        assert!(s["namespace_filter"].is_string());
        assert!(s["id"].is_string());
    }
}

/// #872 (security-high, 2026-05-18) — listing is owner-scoped: the
/// authenticated caller (alice) only sees her own rows; bob's rows
/// must be excluded even though both rows live in the same DB. The
/// caller-supplied `?agent_id=alice` query param is honored as a
/// no-op refinement since it matches the authenticated caller.
#[tokio::test]
async fn h8b_list_subscriptions_agent_id_filter_excludes_others() {
    let state = test_state();
    {
        let lock = state.lock().await;
        crate::subscriptions::insert(
            &lock.0,
            &crate::subscriptions::NewSubscription {
                url: "https://example.com/a",
                events: "*",
                secret: None,
                namespace_filter: Some("ns1"),
                agent_filter: Some("alice"),
                created_by: Some("alice"),
                event_types: None,
            },
        )
        .unwrap();
        crate::subscriptions::insert(
            &lock.0,
            &crate::subscriptions::NewSubscription {
                url: "https://example.com/b",
                events: "*",
                secret: None,
                namespace_filter: Some("ns2"),
                agent_filter: Some("bob"),
                created_by: Some("bob"),
                event_types: None,
            },
        )
        .unwrap();
    }

    let app = Router::new()
        .route(
            "/api/v1/subscriptions",
            axum::routing::get(list_subscriptions),
        )
        .with_state(test_app_state(state.clone()));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions?agent_id=alice")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
    assert_eq!(v["subscriptions"][0]["namespace"], "ns1");
}

// ---- notify (POST /api/v1/notify) ----

/// Happy path: alice notifies bob, the response carries the new id and
/// `delivered_at` stamp; the row lands in bob's `_messages/bob` ns.
#[tokio::test]
async fn h8b_notify_happy_path_creates_message() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "target_agent_id": "bob",
        "title": "Hi bob",
        "payload": "hello there",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["to"], "bob");
    assert!(v["id"].as_str().is_some());
    assert!(v["delivered_at"].as_str().is_some());

    // Row landed in bob's namespace.
    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("_messages/bob"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].title, "Hi bob");
}

/// `target_agent_id` is a required field on `NotifyBody`. Omitting it
/// triggers serde's missing-field rejection (Axum returns 422
/// Unprocessable Entity for malformed JSON shapes).
#[tokio::test]
async fn h8b_notify_missing_target_agent_id_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state));

    // Required field absent — handler never runs.
    let body = serde_json::json!({
        "title": "stray",
        "payload": "no target",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    // Axum rejects with 422 for missing required JSON fields.
    assert!(
        resp.status() == StatusCode::UNPROCESSABLE_ENTITY
            || resp.status() == StatusCode::BAD_REQUEST,
        "expected 4xx for missing target_agent_id, got {}",
        resp.status(),
    );
}

/// `target_agent_id` containing illegal characters (spaces) is rejected
/// downstream by `validate_agent_id` inside `handle_notify`.
#[tokio::test]
async fn h8b_notify_invalid_target_agent_id_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "target_agent_id": "bob with spaces",
        "title": "Hi",
        "payload": "hello",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

/// Oversized payload ( > MAX_CONTENT_SIZE bytes) is rejected by
/// `validate::validate_content` inside `handle_notify`.
#[tokio::test]
async fn h8b_notify_oversized_payload_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state));

    // MAX_CONTENT_SIZE is 65_536; allocate one over.
    let big = "a".repeat(65_537);
    let body = serde_json::json!({
        "target_agent_id": "bob",
        "title": "huge",
        "payload": big,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // Issue #851: the notify endpoint now uses bad_request_opaque,
    // returning the constant sanitized envelope rather than the
    // upstream validate "exceeds max" template. Status-code invariant
    // preserved (BAD_REQUEST above); body asserted on the sanitized
    // constant. The raw "exceeds max" detail is still emitted via
    // tracing::warn for operator visibility.
    assert_eq!(
        v["error"].as_str().unwrap(),
        "invalid request",
        "expected sanitized envelope, got {:?}",
        v["error"],
    );
}

/// `content` is accepted as an alias for `payload` (S32 scenario uses
/// this shape). The notify completes and lands in the target's inbox.
#[tokio::test]
async fn h8b_notify_accepts_content_alias_for_payload() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state));

    let body = serde_json::json!({
        "target_agent_id": "bob",
        "title": "alias",
        "content": "via the content field",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

// ---- get_inbox (GET /api/v1/inbox) ----

/// Empty inbox returns 200 with `count: 0` and an empty `messages` array.
#[tokio::test]
async fn h8b_get_inbox_empty_returns_zero() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/inbox", axum::routing::get(get_inbox))
        .with_state(test_app_state(state));

    // #901: matching X-Agent-Id required for ?agent_id= query.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/inbox?agent_id=alice")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert_eq!(v["messages"].as_array().unwrap().len(), 0);
}

/// After a notify, the inbox surfaces the message with `from`/`title`
/// fields populated; `read=false` indicates the recipient hasn't
/// touched it yet.
#[tokio::test]
async fn h8b_get_inbox_returns_pending_after_notify() {
    let state = test_state();

    // Seed via the notify handler so the full stack is exercised.
    let notify_app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state.clone()));
    let notify_body = serde_json::json!({
        "target_agent_id": "bob",
        "title": "ping",
        "payload": "wake up",
    });
    let resp = notify_app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&notify_body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    // Now fetch bob's inbox.
    let inbox_app = Router::new()
        .route("/api/v1/inbox", axum::routing::get(get_inbox))
        .with_state(test_app_state(state));
    // #901: matching X-Agent-Id required for ?agent_id= query.
    let resp = inbox_app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/inbox?agent_id=bob")
                .header("x-agent-id", "bob")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
    let msg = &v["messages"][0];
    assert_eq!(msg["title"], "ping");
    // `from` is the resolved sender — `handle_notify` calls
    // `identity::resolve_agent_id(None, mcp_client)` which synthesizes the
    // durable `ai:<client>@<host>` form when only `mcp_client` is set
    // (#1720 B1 — pid-free for restart-stable ownership). We accept both the
    // bare and synthesized forms.
    let from = msg["from"].as_str().unwrap();
    assert!(
        from == "alice" || from.starts_with("ai:alice@"),
        "unexpected sender: {from}",
    );
    assert_eq!(msg["read"], false);
}

/// `unread_only=true` filter omits already-read messages. We bump
/// `access_count` directly on the seeded row so the filter has
/// something to skip.
#[tokio::test]
async fn h8b_get_inbox_unread_only_filter_excludes_read() {
    let state = test_state();
    // Seed two messages — one read, one unread — directly via db::insert.
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let unread = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "_messages/alice".into(),
            title: "unread".into(),
            content: "u".into(),
            tags: vec!["_message".into()],
            priority: 5,
            confidence: 1.0,
            source: "notify".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "bob"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let read = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "_messages/alice".into(),
            title: "read".into(),
            content: "r".into(),
            tags: vec!["_message".into()],
            priority: 5,
            confidence: 1.0,
            source: "notify".into(),
            access_count: 5,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "bob"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &unread).unwrap();
        db::insert(&lock.0, &read).unwrap();
    }

    let app = Router::new()
        .route("/api/v1/inbox", axum::routing::get(get_inbox))
        .with_state(test_app_state(state));
    // #901: matching X-Agent-Id required for ?agent_id= query.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/inbox?agent_id=alice&unread_only=true")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 1);
    assert_eq!(v["messages"][0]["title"], "unread");
    assert_eq!(v["unread_only"], true);
}

/// `limit` query param caps the returned list. Insert 3, ask for 2.
#[tokio::test]
async fn h8b_get_inbox_limit_clamps_returned_count() {
    let state = test_state();
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        for i in 0..3 {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Mid,
                namespace: "_messages/alice".into(),
                title: format!("msg-{i}"),
                content: "c".into(),
                tags: vec!["_message".into()],
                priority: 5,
                confidence: 1.0,
                source: "notify".into(),
                access_count: 0,
                created_at: now.clone(),
                updated_at: now.clone(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({"agent_id": "carol"}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }

    let app = Router::new()
        .route("/api/v1/inbox", axum::routing::get(get_inbox))
        .with_state(test_app_state(state));
    // #901: matching X-Agent-Id required for ?agent_id= query.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/inbox?agent_id=alice&limit=2")
                .header("x-agent-id", "alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 2);
}

/// Invalid `agent_id` (illegal char) on the query string is rejected
/// upstream by `resolve_caller_agent_id`. Post-#901 the handler
/// authenticates the X-Agent-Id header FIRST; we send the same
/// invalid id in both header + query so the resolver's
/// `validate_agent_id` returns 400 before any match-check fires.
#[tokio::test]
async fn h8b_get_inbox_invalid_agent_id_rejected() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/inbox", axum::routing::get(get_inbox))
        .with_state(test_app_state(state));

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/inbox?agent_id=bad%20agent")
                .header("x-agent-id", "bad agent")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- session_start (POST /api/v1/session/start) ----

/// Happy path with a valid agent_id: stamps a `session_id` and echoes
/// the agent_id back.
#[tokio::test]
async fn h8b_session_start_with_valid_agent_id_echoes() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/session/start", axum_post(session_start))
        .with_state(state);

    let body = serde_json::json!({"agent_id": "alice"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/session/start")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["session_id"].as_str().is_some());
    assert_eq!(v["agent_id"], "alice");
}

/// `namespace` filter narrows the recent-context preload to that ns.
#[tokio::test]
async fn h8b_session_start_namespace_filter() {
    let state = test_state();
    // Seed two memories, one in `target-ns` and one elsewhere.
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        for (ns, title) in [("target-ns", "in-scope"), ("other-ns", "out")] {
            let mem = Memory {
                id: Uuid::new_v4().to_string(),
                tier: Tier::Long,
                namespace: ns.into(),
                title: title.into(),
                content: "body".into(),
                tags: vec![],
                priority: 5,
                confidence: 1.0,
                source: "api".into(),
                access_count: 0,
                created_at: now.clone(),
                updated_at: now.clone(),
                last_accessed_at: None,
                expires_at: None,
                metadata: serde_json::json!({"agent_id": "alice"}),
                reflection_depth: 0,
                memory_kind: crate::models::MemoryKind::Observation,
                entity_id: None,
                persona_version: None,
                citations: Vec::new(),
                source_uri: None,
                source_span: None,
                confidence_source: crate::models::ConfidenceSource::CallerProvided,
                confidence_signals: None,
                confidence_decayed_at: None,
                version: 1,
                lifecycle_state: crate::models::LifecycleState::Open,
            };
            db::insert(&lock.0, &mem).unwrap();
        }
    }

    let app = Router::new()
        .route("/api/v1/session/start", axum_post(session_start))
        .with_state(state);
    // v0.7.0 #1420 — pass agent_id=alice so the post-list visibility
    // filter admits the seeded alice-owned rows. Pre-#1420 the test
    // ran without a caller and the un-filtered db::list returned
    // everyone's rows.
    let body = serde_json::json!({"namespace": "target-ns", "limit": 5, "agent_id": "alice"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/session/start")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // Only the target-ns memory is in the recent set.
    let mems = v["memories"].as_array().unwrap();
    assert_eq!(mems.len(), 1);
    assert_eq!(mems[0]["title"], "in-scope");
}

/// session_start with no body fields still succeeds — agent_id is
/// optional and the handler stamps a uuid session_id regardless.
#[tokio::test]
async fn h8b_session_start_returns_session_id_without_agent() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/session/start", axum_post(session_start))
        .with_state(state);
    let body = serde_json::json!({});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/session/start")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // session_id present; uuid v4 is 36 chars long.
    let sid = v["session_id"].as_str().unwrap();
    assert_eq!(sid.len(), 36);
    // No explicit agent_id field is added when caller didn't supply one.
    assert!(v.get("agent_id").is_none() || v["agent_id"].is_null());
    assert_eq!(v["mode"], "session_start");
}

/// session_start preloads recent memories from all namespaces when no
/// `namespace` filter is supplied. Verifies the include-all branch.
#[tokio::test]
async fn h8b_session_start_preloads_recent_context() {
    let state = test_state();
    {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "global".into(),
            title: "preload-me".into(),
            content: "context".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "alice"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap();
    }

    let app = Router::new()
        .route("/api/v1/session/start", axum_post(session_start))
        .with_state(state);
    // v0.7.0 #1420 — pass agent_id=alice so the post-list visibility
    // filter admits the seeded alice-owned row.
    let body = serde_json::json!({"limit": 50, "agent_id": "alice"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/session/start")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let mems = v["memories"].as_array().unwrap();
    assert!(
        mems.iter().any(|m| m["title"] == "preload-me"),
        "session_start must preload recent memories",
    );
}
// ========================================================================
// W8/H8c — handlers.rs gap-closing for agents/pending/consolidate.
//
// Coverage targets:
//   list_agents, register_agent, list_pending, approve_pending,
//   reject_pending, consolidate_memories, detect_contradictions,
//   get_capabilities.
//
// All tests drive the real Axum handler via `tower::ServiceExt::oneshot`
// and assert on (status, body) to hit handler arms — including the
// post-validation success paths that earlier W7 tests skipped.
// ========================================================================

// ---- list_agents (GET /api/v1/agents) ----------------------------------

#[tokio::test]
async fn http_list_agents_empty_returns_zero_count() {
    // Empty `_agents` namespace: count=0, agents=[].
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_get(list_agents))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
    assert_eq!(v["agents"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn http_list_agents_returns_registered_rows() {
    // Pre-register two agents directly via db::register_agent and
    // confirm both surface through the list handler.
    let state = test_state();
    {
        let lock = state.lock().await;
        db::register_agent(&lock.0, "alice", "human", &["read".into(), "write".into()]).unwrap();
        db::register_agent(&lock.0, "bob", "ai:claude-opus-4.7", &["recall".into()]).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/agents", axum_get(list_agents))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 2);
    let agents = v["agents"].as_array().unwrap();
    let ids: Vec<&str> = agents
        .iter()
        .filter_map(|a| a["agent_id"].as_str())
        .collect();
    assert!(ids.contains(&"alice"));
    assert!(ids.contains(&"bob"));
}

#[tokio::test]
async fn http_list_agents_includes_types_and_capabilities() {
    // The serialized agent rows must surface agent_type AND the
    // capability list back to the caller — not just agent_id.
    let state = test_state();
    {
        let lock = state.lock().await;
        db::register_agent(
            &lock.0,
            "alpha",
            "ai:claude-opus-4.7",
            &["read".into(), "store".into(), "recall".into()],
        )
        .unwrap();
    }
    let app = Router::new()
        .route("/api/v1/agents", axum_get(list_agents))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let agents = v["agents"].as_array().unwrap();
    assert_eq!(agents.len(), 1);
    let a = &agents[0];
    assert_eq!(a["agent_id"], "alpha");
    assert_eq!(a["agent_type"], "ai:claude-opus-4.7");
    let caps = a["capabilities"].as_array().unwrap();
    assert_eq!(caps.len(), 3);
    let cap_strs: Vec<&str> = caps.iter().filter_map(|c| c.as_str()).collect();
    assert!(cap_strs.contains(&"read"));
    assert!(cap_strs.contains(&"store"));
    assert!(cap_strs.contains(&"recall"));
}

// ---- register_agent (POST /api/v1/agents) ------------------------------

#[tokio::test]
async fn http_register_agent_happy_path_returns_created() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_post(register_agent))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "agent_id": "alice",
        "agent_type": "human",
        "capabilities": ["read", "write"]
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["registered"], true);
    assert_eq!(v["agent_id"], "alice");
    assert_eq!(v["agent_type"], "human");
    // Row landed in `_agents` namespace.
    let lock = state.lock().await;
    let agents = db::list_agents(&lock.0).unwrap();
    assert_eq!(agents.len(), 1);
    assert_eq!(agents[0].agent_id, "alice");
}

#[tokio::test]
async fn http_register_agent_missing_agent_type_400() {
    // Missing `agent_type` on the JSON body — Axum's Json extractor
    // rejects with 4xx (422 from serde-error wrapping).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_post(register_agent))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "agent_id": "alice"
        // no agent_type, no capabilities
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(
        resp.status().is_client_error(),
        "expected 4xx for missing agent_type, got {}",
        resp.status()
    );
}

#[tokio::test]
async fn http_register_agent_invalid_agent_id_with_space_400() {
    // validate_agent_id rejects spaces.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_post(register_agent))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "agent_id": "bad agent",
        "agent_type": "human",
        "capabilities": []
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_register_agent_duplicate_register_idempotent_preserves_registered_at() {
    // Re-registering the same agent_id is allowed (UPSERT-style on
    // (namespace, title)). Both calls return 201; registered_at is
    // preserved across the second call (db::register_agent reads it back).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_post(register_agent))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "agent_id": "twice",
        "agent_type": "human",
        "capabilities": ["read"]
    });
    let r1 = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(r1.status(), StatusCode::CREATED);
    let r2 = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(r2.status(), StatusCode::CREATED);
    // Only one row for this agent_id (LWW on title=agent:twice).
    let lock = state.lock().await;
    let agents = db::list_agents(&lock.0).unwrap();
    let twice: Vec<_> = agents.iter().filter(|a| a.agent_id == "twice").collect();
    assert_eq!(
        twice.len(),
        1,
        "duplicate register must collapse to one row"
    );
}

#[tokio::test]
async fn http_register_agent_capabilities_array_preserved() {
    // The full `capabilities` array round-trips through register +
    // list. Specifically: order-insensitive coverage of all members.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/agents", axum_post(register_agent))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "agent_id": "capper",
        "agent_type": "ai:claude-opus-4.7",
        "capabilities": ["search", "store", "recall", "consolidate"]
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/agents")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let echoed = v["capabilities"].as_array().unwrap();
    assert_eq!(echoed.len(), 4);
    // And persisted shape matches.
    let lock = state.lock().await;
    let agents = db::list_agents(&lock.0).unwrap();
    let me = agents.iter().find(|a| a.agent_id == "capper").unwrap();
    assert_eq!(me.capabilities.len(), 4);
    assert!(me.capabilities.contains(&"search".to_string()));
    assert!(me.capabilities.contains(&"store".to_string()));
    assert!(me.capabilities.contains(&"recall".to_string()));
    assert!(me.capabilities.contains(&"consolidate".to_string()));
}

// ---- list_pending (GET /api/v1/pending) --------------------------------

#[tokio::test]
async fn http_list_pending_with_pending_actions_returns_them() {
    // Queue two pending actions and confirm both surface.
    // #958 (security-medium, 2026-05-20) — `/api/v1/pending` is now
    // caller-gated. To exercise the legacy cross-requester listing
    // semantic this test was written for, route through
    // `test_app_state_with_admin("ops:admin")` + the matching
    // `X-Agent-Id` header so the admin allowlist bypass admits the
    // cross-tenant view. The non-admin filter contract is covered by
    // `tests/list_pending_owner_gate_958.rs`.
    use crate::models::GovernedAction;
    let state = test_state();
    {
        let lock = state.lock().await;
        db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "ns-a",
            None,
            "alice",
            &serde_json::json!({"title": "first", "content": "c1"}),
        )
        .unwrap();
        db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "ns-b",
            None,
            "bob",
            &serde_json::json!({"title": "second", "content": "c2"}),
        )
        .unwrap();
    }
    let app = Router::new()
        .route("/api/v1/pending", axum_get(list_pending))
        .with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 2);
    assert_eq!(v["pending"].as_array().unwrap().len(), 2);
}

#[tokio::test]
async fn http_list_pending_filters_by_status_pending() {
    // #958 — list_pending caller-gated. Route through the admin
    // bypass + `X-Agent-Id: ops:admin` so the status filter under
    // test (status=pending) is exercised on the unfiltered queue.
    // Caller-scoped filtering is covered by
    // `tests/list_pending_owner_gate_958.rs`.
    use crate::models::GovernedAction;
    let state = test_state();
    let kept_id = {
        let lock = state.lock().await;
        // One pending action that stays pending.
        let id = db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "ns-keep",
            None,
            "alice",
            &serde_json::json!({"title": "stay", "content": "x"}),
        )
        .unwrap();
        // One that we mark rejected.
        let other = db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "ns-reject",
            None,
            "alice",
            &serde_json::json!({"title": "out", "content": "x"}),
        )
        .unwrap();
        db::decide_pending_action(&lock.0, &other, false, "alice").unwrap();
        id
    };
    let app = Router::new()
        .route("/api/v1/pending", axum_get(list_pending))
        .with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending?status=pending")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let items = v["pending"].as_array().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["id"], kept_id);
    assert_eq!(items[0]["status"], "pending");
}

#[tokio::test]
async fn http_list_pending_filters_by_status_rejected() {
    // #958 — list_pending caller-gated. Admin bypass + matching
    // header so the `status=rejected` query path is exercised on
    // the unfiltered queue.
    use crate::models::GovernedAction;
    let state = test_state();
    {
        let lock = state.lock().await;
        let id = db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "ns-r",
            None,
            "alice",
            &serde_json::json!({"title": "rejected", "content": "x"}),
        )
        .unwrap();
        db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
        // Pending one to verify it doesn't leak through.
        db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "ns-p",
            None,
            "alice",
            &serde_json::json!({"title": "pending", "content": "x"}),
        )
        .unwrap();
    }
    let app = Router::new()
        .route("/api/v1/pending", axum_get(list_pending))
        .with_state(test_app_state_with_admin(state.clone(), "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending?status=rejected&limit=10")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let items = v["pending"].as_array().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["status"], "rejected");
}

#[tokio::test]
async fn http_list_pending_limit_clamped_to_1000() {
    // Pass a deliberately-large limit; handler clamps to 1000 but
    // still returns 200 (we just verify the ceiling path executes).
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending", axum_get(list_pending))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending?limit=99999")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- approve_pending (POST /api/v1/pending/{id}/approve) ---------------

#[tokio::test]
async fn http_approve_pending_happy_path_executes_store() {
    // Queue a Store payload, approve it, expect 200 + executed=true +
    // a memory_id we can fetch back.
    // S5-C1 (2026-05-13): /approve is now HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    use crate::models::GovernedAction;
    let state = test_state();
    let now_rfc = Utc::now().to_rfc3339();
    let pending_id = {
        let lock = state.lock().await;
        // #1796 (5-agent vote 4d3ea1c5) — HTTP approve surface enforces the
        // Human-arm gate UNCONDITIONALLY: register the distinct "approver-alice".
        db::register_agent(&lock.0, "approver-alice", "ai:generic", &[]).ok();
        db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "approve-ns",
            None,
            "alice",
            &serde_json::json!({
                "id": Uuid::new_v4().to_string(),
                "tier": Tier::Long.as_str(),
                "namespace": "approve-ns",
                "title": "approved-store",
                "content": "executed via approval",
                "tags": [],
                "priority": 5,
                "confidence": 1.0,
                "source": "api",
                "access_count": 0,
                "created_at": now_rfc,
                "updated_at": now_rfc,
                "metadata": {}
            }),
        )
        .unwrap()
    };
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state.clone()));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pending_id, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{pending_id}/approve"))
                .method("POST")
                .header("x-agent-id", "approver-alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(status, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["approved"], true);
    assert_eq!(v["executed"], true);
    assert_eq!(v["decided_by"], "approver-alice");
    // Status is now 'approved' in the row.
    let lock = state.lock().await;
    let pa = db::get_pending_action(&lock.0, &pending_id)
        .unwrap()
        .unwrap();
    assert_eq!(pa.status, "approved");
    assert_eq!(pa.decided_by.as_deref(), Some("approver-alice"));
}

#[tokio::test]
async fn http_approve_pending_invalid_id_format_400() {
    // validate_id rejects ids with embedded control chars — handler
    // returns 400 BEFORE touching the DB. We use %01 (SOH) which
    // is_clean_string flags as invalid.
    // S5-C1 (2026-05-13): /approve is HMAC-gated; sign so the
    // body reaches validate_id (which is what we're pinning).
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", "bad\x01id", b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending/bad%01id/approve")
                .method("POST")
                .header("x-agent-id", "alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_approve_pending_already_approved_is_rejected() {
    // Once an action is decided, a follow-up approve must NOT execute
    // again — it returns FORBIDDEN with `approve rejected: already decided`.
    // S5-C1 (2026-05-13): /approve is HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    use crate::models::GovernedAction;
    let state = test_state();
    let pid = {
        let lock = state.lock().await;
        let id = db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "double-approve",
            None,
            "alice",
            &serde_json::json!({
                "tier": Tier::Long.as_str(),
                "namespace": "double-approve",
                "title": "store",
                "content": "x",
                "tags": [], "priority": 5, "confidence": 1.0,
                "source": "api", "metadata": {}
            }),
        )
        .unwrap();
        db::decide_pending_action(&lock.0, &id, true, "alice").unwrap();
        id
    };
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{pid}/approve"))
                .method("POST")
                .header("x-agent-id", "alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(status, StatusCode::FORBIDDEN);
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let err = v["error"].as_str().unwrap_or("");
    assert!(
        err.contains("already decided") || err.contains("rejected"),
        "expected already-decided message, got {err}"
    );
}

#[tokio::test]
async fn http_approve_pending_executor_records_decided_by() {
    // After a successful approve the row's decided_by is the same id
    // we passed via X-Agent-Id, not the requester. This is the
    // executor-records-approval invariant.
    // S5-C1 (2026-05-13): /approve is HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    use crate::models::GovernedAction;
    let state = test_state();
    let now_rfc = Utc::now().to_rfc3339();
    let pid = {
        let lock = state.lock().await;
        // #1796 (5-agent vote 4d3ea1c5) — HTTP approve surface enforces the
        // Human-arm gate UNCONDITIONALLY: register the distinct "executor-claude".
        db::register_agent(&lock.0, "executor-claude", "ai:generic", &[]).ok();
        db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "executor-ns",
            None,
            "requester-bob",
            &serde_json::json!({
                "id": Uuid::new_v4().to_string(),
                "tier": Tier::Long.as_str(),
                "namespace": "executor-ns",
                "title": "e",
                "content": "y",
                "tags": [], "priority": 5, "confidence": 1.0,
                "source": "api",
                "access_count": 0,
                "created_at": now_rfc,
                "updated_at": now_rfc,
                "metadata": {}
            }),
        )
        .unwrap()
    };
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state.clone()));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{pid}/approve"))
                .method("POST")
                .header("x-agent-id", "executor-claude")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::OK);
    let lock = state.lock().await;
    let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
    assert_eq!(pa.requested_by, "requester-bob");
    assert_eq!(pa.decided_by.as_deref(), Some("executor-claude"));
    assert_eq!(pa.status, "approved");
}

#[tokio::test]
async fn http_approve_pending_returns_memory_id_for_store_payload() {
    // happy-path Store: the response carries a memory_id and that
    // memory is queryable via db::get.
    use crate::models::GovernedAction;
    let state = test_state();
    let now_rfc = Utc::now().to_rfc3339();
    let pid = {
        let lock = state.lock().await;
        // #1796 (5-agent vote 4d3ea1c5) — HTTP approve surface enforces the
        // Human-arm gate UNCONDITIONALLY (no self-approval; registered approver).
        // Requester "alice" queues; a distinct registered "approver-store" decides.
        db::register_agent(&lock.0, "approver-store", "ai:generic", &[]).ok();
        db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "executed-write",
            None,
            "alice",
            &serde_json::json!({
                "id": Uuid::new_v4().to_string(),
                "tier": Tier::Long.as_str(),
                "namespace": "executed-write",
                "title": "executed-mem",
                "content": "this exists after approval",
                "tags": [], "priority": 5, "confidence": 1.0,
                "source": "api",
                "access_count": 0,
                "created_at": now_rfc,
                "updated_at": now_rfc,
                "metadata": {}
            }),
        )
        .unwrap()
    };
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state.clone()));
    // S5-C1 (2026-05-13): /approve is HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{pid}/approve"))
                .method("POST")
                .header("x-agent-id", "approver-store")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(status, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let mem_id = v["memory_id"].as_str().expect("memory_id present");
    let lock = state.lock().await;
    let mem = db::get(&lock.0, mem_id).unwrap().expect("memory exists");
    assert_eq!(mem.title, "executed-mem");
    assert_eq!(mem.namespace, "executed-write");
}

// ---- reject_pending (POST /api/v1/pending/{id}/reject) -----------------

#[tokio::test]
async fn http_reject_pending_happy_path_marks_rejected_no_execution() {
    // Reject path: row goes to status='rejected', decided_by stamped,
    // and NO underlying memory is created.
    // S5-C1 (2026-05-13): /reject is HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    use crate::models::GovernedAction;
    let state = test_state();
    let pid = {
        let lock = state.lock().await;
        db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "reject-ns",
            None,
            "alice",
            &serde_json::json!({
                "tier": Tier::Long.as_str(),
                "namespace": "reject-ns",
                "title": "blocked",
                "content": "must not be created",
                "tags": [], "priority": 5, "confidence": 1.0,
                "source": "api", "metadata": {}
            }),
        )
        .unwrap()
    };
    let app = Router::new()
        .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
        .with_state(test_app_state(state.clone()));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{pid}/reject"))
                .method("POST")
                .header("x-agent-id", "rejector-alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(status, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["rejected"], true);
    assert_eq!(v["decided_by"], "rejector-alice");
    let lock = state.lock().await;
    let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
    assert_eq!(pa.status, "rejected");
    // Confirm no memory landed in `reject-ns`.
    let rows = db::list(
        &lock.0,
        Some("reject-ns"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert!(
        rows.is_empty(),
        "rejection must not execute the queued payload"
    );
}

#[tokio::test]
async fn http_reject_pending_already_rejected_returns_404() {
    // Once decided, decide_pending_action returns false; the handler
    // surfaces this as 404 ("not found or already decided").
    // S5-C1 (2026-05-13): /reject is HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    use crate::models::GovernedAction;
    let state = test_state();
    let pid = {
        let lock = state.lock().await;
        let id = db::queue_pending_action(
            &lock.0,
            GovernedAction::Store,
            "double-reject",
            None,
            "alice",
            &serde_json::json!({
                "tier": Tier::Long.as_str(),
                "namespace": "double-reject",
                "title": "x",
                "content": "x",
                "tags": [], "priority": 5, "confidence": 1.0,
                "source": "api", "metadata": {}
            }),
        )
        .unwrap();
        db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
        id
    };
    let app = Router::new()
        .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
        .with_state(test_app_state(state));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &pid, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{pid}/reject"))
                .method("POST")
                .header("x-agent-id", "alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_reject_pending_invalid_id_format_400() {
    // validate_id flags ids containing control chars; %01 hits that
    // arm and returns 400 before any DB lookup.
    // S5-C1 (2026-05-13): /reject is HMAC-gated.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
        .with_state(test_app_state(state));
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", "bad\x01id", b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending/bad%01id/reject")
                .method("POST")
                .header("x-agent-id", "alice")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- consolidate_memories (POST /api/v1/consolidate) -------------------

#[tokio::test]
async fn http_consolidate_two_into_one_happy_path() {
    // Insert two memories, consolidate them, expect 201 with a new
    // memory id and the originals removed.
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    let (id_a, id_b) = {
        let lock = state.lock().await;
        let mk = |title: &str| Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "merge-ns".into(),
            title: title.into(),
            content: format!("body for {title}"),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "alice"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let a = db::insert(&lock.0, &mk("draft-a")).unwrap();
        let b = db::insert(&lock.0, &mk("draft-b")).unwrap();
        (a, b)
    };
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "ids": [id_a, id_b],
        "title": "merged-result",
        "summary": "a merge of two drafts",
        "namespace": "merge-ns",
        "tier": Tier::Long.as_str()
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "consolidator")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["consolidated"], 2);
    let new_id = v["id"].as_str().unwrap();
    let lock = state.lock().await;
    let merged = db::get(&lock.0, new_id).unwrap().unwrap();
    assert_eq!(merged.title, "merged-result");
    assert_eq!(merged.namespace, "merge-ns");
    // Originals removed.
    assert!(db::get(&lock.0, &id_a).unwrap().is_none());
    assert!(db::get(&lock.0, &id_b).unwrap().is_none());
}

#[tokio::test]
async fn http_consolidate_fans_out_to_peer_1552() {
    // #1552 — the consolidate write path must broadcast the merged memory +
    // source deletions to the federation quorum (shared `consolidate_fanout`
    // helper, exercised here through the sqlite branch). With W=2 and one
    // Ack-ing peer the quorum is met → 201, and the peer's `/sync/push` must
    // have received at least one fanout POST.
    use std::sync::atomic::Ordering;
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    let (id_a, id_b) = {
        let lock = state.lock().await;
        let mk = |title: &str| Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "merge-fed-ns".into(),
            title: title.into(),
            content: format!("body for {title}"),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "alice"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let a = db::insert(&lock.0, &mk("fed-draft-a")).unwrap();
        let b = db::insert(&lock.0, &mk("fed-draft-b")).unwrap();
        (a, b)
    };
    let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
    let app_state = h8d_app_state_with_fed(state.clone(), vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(app_state);
    let body = serde_json::json!({
        "ids": [id_a, id_b],
        "title": "fed-merged-result",
        "summary": "a merge of two drafts that must federate",
        "namespace": "merge-fed-ns",
        "tier": Tier::Long.as_str()
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "consolidator")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    assert!(
        count.load(Ordering::Relaxed) >= 1,
        "consolidate must broadcast the merged memory to the federation quorum"
    );
}

#[tokio::test]
async fn http_reflect_fans_out_to_peer_1552() {
    // #1552 — the reflect write path must broadcast the new reflection memory
    // (and its `reflects_on` edges) to the federation quorum (shared
    // `reflect_fanout` helper, exercised here through the sqlite branch).
    // Previously the reflect handler returned WITHOUT any fanout, leaving the
    // reflection to converge only via async catch-up. With W=2 and one Ack-ing
    // peer the quorum is met → 200, and the peer must have received ≥1 POST.
    use std::sync::atomic::Ordering;
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    let base_id = {
        let lock = state.lock().await;
        let base = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "reflect-fed-ns".into(),
            title: "reflect-base".into(),
            content: "base observation to reflect upon".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "alice"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &base).unwrap()
    };
    let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
    let app_state = h8d_app_state_with_fed(state.clone(), vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/memory_reflect", axum_post(handle_reflect_http))
        .with_state(app_state);
    let body = serde_json::json!({
        "source_ids": [base_id],
        "title": "reflection-1",
        "content": "a reflection on the base observation",
        "namespace": "reflect-fed-ns",
        "agent_id": "alice",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memory_reflect")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert!(
        count.load(Ordering::Relaxed) >= 1,
        "reflect must broadcast the reflection to the federation quorum"
    );
}

#[tokio::test]
async fn http_consolidate_single_id_400() {
    // validate_consolidate requires ≥2 ids — single-id calls are
    // rejected up front with 400.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "ids": [Uuid::new_v4().to_string()],
        "title": "lone-merge",
        "summary": "only one source",
        "namespace": "merge-ns"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_consolidate_invalid_namespace_400() {
    // Namespace with a space fails validate_namespace.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "ids": [Uuid::new_v4().to_string(), Uuid::new_v4().to_string()],
        "title": "merge",
        "summary": "x",
        "namespace": "bad ns"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_consolidate_invalid_agent_id_400() {
    // X-Agent-Id with a space → identity::resolve_http_agent_id error → 400.
    let state = test_state();
    let id_a = Uuid::new_v4().to_string();
    let id_b = Uuid::new_v4().to_string();
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "ids": [id_a, id_b],
        "title": "merge",
        "summary": "x",
        "namespace": "merge-ns"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "bad agent id")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_consolidate_max_id_count_cap_exceeded_400() {
    // validate_consolidate caps at 100 ids.
    let state = test_state();
    let ids: Vec<String> = (0..101).map(|_| Uuid::new_v4().to_string()).collect();
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "ids": ids,
        "title": "too-many",
        "summary": "x",
        "namespace": "merge-ns"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_consolidate_missing_source_500() {
    // Two well-formed UUIDs but the rows don't exist — db::consolidate
    // bails inside the transaction, surface as 500. This covers the
    // post-validation error arm of the handler.
    let state = test_state();
    let id_a = Uuid::new_v4().to_string();
    let id_b = Uuid::new_v4().to_string();
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "ids": [id_a, id_b],
        "title": "merge",
        "summary": "x",
        "namespace": "merge-ns"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

// ---- detect_contradictions (GET /api/v1/contradictions) ----------------

#[tokio::test]
async fn http_contradictions_empty_no_pairs() {
    // namespace exists in the URL but no memories → empty memories,
    // empty links. Still a 200.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/contradictions", axum_get(detect_contradictions))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/contradictions?namespace=empty-ns")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["memories"].as_array().unwrap().len(), 0);
    assert_eq!(v["links"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn http_contradictions_synthesizes_links_for_same_title() {
    // Two memories with the same TITLE but different content in a
    // namespace produce a synthesized contradicts link.
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    {
        let lock = state.lock().await;
        // Same title forces UPSERT collapse, so vary metadata.topic for grouping.
        let mk = |title: &str, content: &str| Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "contradict-ns".into(),
            title: title.into(),
            content: content.into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"topic": "earth-shape"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mk("alice-says", "earth is round")).unwrap();
        db::insert(&lock.0, &mk("bob-says", "earth is flat")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/contradictions", axum_get(detect_contradictions))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/contradictions?namespace=contradict-ns&topic=earth-shape")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let memories = v["memories"].as_array().unwrap();
    assert_eq!(memories.len(), 2);
    let links = v["links"].as_array().unwrap();
    assert!(links.iter().any(|l| {
        l["relation"].as_str() == Some("contradicts") && l["synthesized"].as_bool() == Some(true)
    }));
}

#[tokio::test]
async fn http_contradictions_namespace_filter_isolates_results() {
    // Memories in ns-A vs ns-B — querying ns-A only returns its rows
    // even though ns-B has a same-titled candidate.
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    {
        let lock = state.lock().await;
        let mk = |ns: &str, content: &str| Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: ns.into(),
            title: "shared-topic".into(),
            content: content.into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "api".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mk("ns-iso-a", "first opinion")).unwrap();
        db::insert(&lock.0, &mk("ns-iso-b", "different opinion")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/contradictions", axum_get(detect_contradictions))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/contradictions?namespace=ns-iso-a")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let memories = v["memories"].as_array().unwrap();
    assert_eq!(memories.len(), 1, "ns filter must isolate results");
    assert_eq!(memories[0]["namespace"], "ns-iso-a");
}

#[tokio::test]
async fn http_contradictions_invalid_namespace_400() {
    // A namespace string with a space fails validate_namespace
    // before any DB read.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/contradictions", axum_get(detect_contradictions))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/contradictions?namespace=bad%20ns")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- get_capabilities (GET /api/v1/capabilities) -----------------------

#[tokio::test]
async fn http_capabilities_returns_expected_shape() {
    // Confirm the response includes tier/version/features/models —
    // the four top-level keys our scenarios depend on.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/capabilities", axum_get(get_capabilities))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/capabilities")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v.get("tier").is_some(), "missing `tier`");
    assert!(v.get("version").is_some(), "missing `version`");
    assert!(v.get("features").is_some(), "missing `features`");
    assert!(v.get("models").is_some(), "missing `models`");
    // The Keyword tier defaults: keyword_search=true, no LLM features.
    assert_eq!(v["features"]["keyword_search"], true);
    assert_eq!(v["features"]["semantic_search"], false);
    assert_eq!(v["features"]["query_expansion"], false);
}

/// v0.6.3.1 (capabilities schema v2 — P1 honesty patch).
/// HTTP surface mirrors the MCP shape: every new top-level block is
/// present, `schema_version="2"`, and dropped fields are absent.
#[tokio::test]
async fn http_capabilities_v2_schema_includes_all_blocks() {
    // v0.7.0 K3: serialize against the gate-mode atomic and clear
    // any sibling-test override so `permissions.mode` reflects
    // the documented zero-state default (`advisory`).
    let _gate = crate::config::lock_permissions_mode_for_test();
    crate::config::clear_permissions_mode_override_for_test();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/capabilities", axum_get(get_capabilities))
        .with_state(test_app_state(state));
    // v0.7.0 A5: HTTP `/capabilities` defaults to v3 now; pin v2
    // explicitly via `Accept-Capabilities` to keep this test
    // exercising the v2 backward-compat contract. v2 wire shape
    // stays unchanged indefinitely; this test is the proof.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/capabilities")
                .header("accept-capabilities", "v2")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    assert_eq!(v["schema_version"], "2");

    // permissions: mode=advisory (P1), active_rules live, no rule_summary
    assert!(v["permissions"].is_object());
    assert_eq!(v["permissions"]["mode"], "advisory");
    assert!(v["permissions"]["active_rules"].is_number());
    assert!(v["permissions"].get("rule_summary").is_none());
    // v0.6.3.1 (P4, audit G1): inheritance posture surfaced.
    assert_eq!(v["permissions"]["inheritance"], "enforced");

    // hooks: registered_count live, no by_event
    assert!(v["hooks"].is_object());
    assert!(v["hooks"]["registered_count"].is_number());
    assert!(v["hooks"].get("by_event").is_none());

    // compaction: planned-feature shape
    assert!(v["compaction"].is_object());
    assert_eq!(v["compaction"]["planned"], true);
    assert_eq!(v["compaction"]["enabled"], false);
    assert_eq!(v["compaction"]["version"], "v0.8+");

    // approval: pending_requests live, no subscribers/timeout
    assert!(v["approval"].is_object());
    assert!(v["approval"]["pending_requests"].is_number());
    assert!(v["approval"].get("subscribers").is_none());
    assert!(v["approval"].get("default_timeout_seconds").is_none());

    // v0.7.0 #1324 — transcripts substrate shipped at v0.7.0.
    // Capability flag reads `planned: false, enabled: false` at
    // zero-state (no rows in memory_transcripts yet).
    assert!(v["transcripts"].is_object());
    assert_eq!(v["transcripts"]["planned"], false);
    assert_eq!(v["transcripts"]["enabled"], false);

    // P1: live recall/reranker mode tags present (default tier
    // here is keyword with no embedder → disabled / off).
    assert_eq!(v["features"]["recall_mode_active"], "disabled");
    assert_eq!(v["features"]["reranker_active"], "off");
    // memory_reflection reshaped to a planned object. v0.7.0
    // recursive-learning (issue #655) Tasks 1-6 shipped the
    // primitive, so the flag now reports `planned=false,
    // enabled=true`.
    assert_eq!(v["features"]["memory_reflection"]["planned"], false);
    assert_eq!(v["features"]["memory_reflection"]["enabled"], true);
}

#[tokio::test]
async fn http_capabilities_version_matches_pkg_version() {
    // version must equal CARGO_PKG_VERSION — operators pin scenarios
    // by this string, regressions here break upgrade tooling.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/capabilities", axum_get(get_capabilities))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/capabilities")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
    assert_eq!(v["tier"], "keyword");
}
// ====================================================================
// W8/H8d — dual-form `*_qs` namespace handlers + `fanout_or_503` matrix
// --------------------------------------------------------------------
// `set/get/clear_namespace_standard_qs` are the query-string twins of
// the path-form handlers used by S34/S35 (`/api/v1/namespaces?namespace=…`).
// The QS-form arms were uncovered prior to this batch — both the
// happy paths and the 400-on-missing-namespace branches needed direct
// exercise. The `fanout_or_503` 503 paths are exercised through the
// QS-form `set` handler (`set_namespace_standard_inner` calls
// `fanout_or_503` for the standard memory and then
// `broadcast_namespace_meta_quorum` for the meta row); the same
// mock-peer helper used by the W3 federation tests drives both.
// ====================================================================

// --- helpers shared across the W8/H8d tests --------------------------

/// Spawn a mock peer that records every `POST /api/v1/sync/push` and
/// responds according to `behaviour`. Returns the base URL and the
/// shared call-counter so tests can both target the peer and assert
/// how many fanout POSTs reached it.
async fn h8d_spawn_mock_peer(
    behaviour: H8dPeerBehaviour,
) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
    use std::sync::atomic::{AtomicUsize, Ordering};
    use tokio::net::TcpListener;

    let count = Arc::new(AtomicUsize::new(0));
    let count_for_peer = count.clone();
    #[derive(Clone)]
    struct PeerState {
        count: Arc<AtomicUsize>,
        behaviour: H8dPeerBehaviour,
    }
    async fn handler(
        axum::extract::State(s): axum::extract::State<PeerState>,
        Json(_body): Json<serde_json::Value>,
    ) -> (StatusCode, Json<serde_json::Value>) {
        s.count.fetch_add(1, Ordering::Relaxed);
        match s.behaviour {
            H8dPeerBehaviour::Ack => (
                StatusCode::OK,
                Json(json!({"applied": 1, "noop": 0, "skipped": 0})),
            ),
            H8dPeerBehaviour::Fail500 => (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"error": "stub failure"})),
            ),
            H8dPeerBehaviour::Fail503 => (
                StatusCode::SERVICE_UNAVAILABLE,
                Json(json!({"error": "stub unavailable"})),
            ),
            H8dPeerBehaviour::Fail400 => (
                StatusCode::BAD_REQUEST,
                Json(json!({"error": "stub bad request"})),
            ),
            H8dPeerBehaviour::Hang => {
                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
                (StatusCode::OK, Json(json!({"applied": 1})))
            }
        }
    }
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(handler))
        .with_state(PeerState {
            count: count_for_peer,
            behaviour,
        });
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, app).await.ok();
    });
    (format!("http://{addr}"), count)
}

#[derive(Clone, Copy)]
enum H8dPeerBehaviour {
    /// Always returns 200 OK with the standard ack envelope.
    Ack,
    /// Always returns 500 Internal Server Error.
    Fail500,
    /// Always returns 503 Service Unavailable.
    Fail503,
    /// Always returns 400 Bad Request.
    Fail400,
    /// Sleeps 10s before responding — exercises timeout / unreachable
    /// classification when `--quorum-timeout-ms` is shorter.
    Hang,
}

/// Build an `AppState` wired to a `FederationConfig` that points at
/// `peer_urls` with quorum width `w` and the given timeout. Mirrors
/// the construction used by `http_bulk_create_fans_out_with_federation`.
fn h8d_app_state_with_fed(db: Db, peer_urls: Vec<String>, w: usize, timeout_ms: u64) -> AppState {
    let fed = crate::federation::FederationConfig::build(
        w,
        &peer_urls,
        std::time::Duration::from_millis(timeout_ms),
        None,
        None,
        None,
        "ai:h8d-test".to_string(),
        None,
    )
    .unwrap()
    .expect("federation must be built");
    AppState {
        db,
        embedder: Arc::new(None),
        vector_index: Arc::new(Mutex::new(None)),
        federation: Arc::new(Some(fed)),
        tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
        scoring: Arc::new(crate::config::ResolvedScoring::default()),
        profile: Arc::new(crate::profile::Profile::core()),
        mcp_config: Arc::new(None),
        active_keypair: Arc::new(None),
        family_embeddings: Arc::new(RwLock::new(Some(Vec::new()))),
        storage_backend: StorageBackend::Sqlite,
        #[cfg(feature = "sal")]
        store: test_sqlite_store_handle(),
        llm: Arc::new(None),
        auto_tag_model: Arc::new(None),
        llm_call_timeout: std::time::Duration::from_secs(
            crate::config::DEFAULT_LLM_CALL_TIMEOUT_SECS,
        ),
        replay_cache: Arc::new(crate::identity::replay::ReplayCache::new()),
        verify_require_nonce: false,
        federation_nonce_cache: Arc::new(crate::identity::replay::FederationNonceCache::new()),
        autonomous_hooks: false,
        recall_scope: Arc::new(None),
        deferred_audit_queue: Arc::new(None),
        admin_agent_ids: Arc::new(Vec::new()),
        rule_cache: std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new()),
        resolved_models: std::sync::Arc::new(crate::config::ResolvedModels::default()),
        runtime: crate::runtime_context::RuntimeContext::global_arc(),
        max_page_size: crate::handlers::MAX_BULK_SIZE,
    }
}

// --- get_namespace_standard_qs --------------------------------------

#[tokio::test]
async fn http_get_namespace_standard_qs_returns_standard_for_existing_ns() {
    // Pre-seed a namespace standard via the inner DB call so we can
    // assert the QS handler reads it back. We use the path-form set
    // handler with no federation so the write is local-only.
    let state = test_state();
    let app_state = test_app_state(state.clone());
    let set_router = Router::new()
        .route(
            "/api/v1/namespaces/{ns}/standard",
            axum_post(set_namespace_standard),
        )
        .with_state(app_state);
    let resp = set_router
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces/qs-existing/standard")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    // Now fetch via the QS form. Should return 200 with the standard
    // payload (namespace + standard_id).
    let get_router = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::get(get_namespace_standard_qs),
        )
        .with_state(test_app_state(state.clone()));
    let resp = get_router
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces?namespace=qs-existing")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["namespace"], "qs-existing");
    assert!(v["standard_id"].is_string(), "standard_id must be set");
}

#[tokio::test]
async fn http_get_namespace_standard_qs_returns_null_for_missing_ns_record() {
    // A namespace that has never had a standard set returns the same
    // `{namespace, standard_id: null}` envelope the path-form does —
    // the MCP handler differentiates by `standard_id == null`.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::get(get_namespace_standard_qs),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces?namespace=qs-never-set")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["namespace"], "qs-never-set");
    assert!(
        v["standard_id"].is_null(),
        "standard_id must be null for an unset namespace"
    );
}

#[tokio::test]
async fn http_get_namespace_standard_qs_falls_through_to_list_on_missing_param() {
    // The QS-form GET deliberately reuses the bare /api/v1/namespaces
    // route — when `?namespace=` is absent it must delegate to
    // `list_namespaces`, NOT 400. This pins the chained-route contract
    // documented inline at the handler.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::get(get_namespace_standard_qs),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["namespaces"].is_array(),
        "fallthrough must produce the list shape, got {v:?}"
    );
}

#[tokio::test]
async fn http_get_namespace_standard_qs_inherit_flag_returns_chain() {
    // Cover the `?inherit=true` arm, which routes through the
    // `chain` / `standards` branch of `handle_namespace_get_standard`.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::get(get_namespace_standard_qs),
        )
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces?namespace=child&inherit=true")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["chain"].is_array(), "inherit must surface the chain");
    assert!(v["standards"].is_array());
}

#[tokio::test]
async fn http_get_namespace_standard_qs_invalid_namespace_returns_400() {
    // Ultrareview #337 — URL-decoded namespace flows through
    // `validate_namespace`. A namespace with disallowed bytes must
    // surface as 400 from the handler, not 500.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::get(get_namespace_standard_qs),
        )
        .with_state(test_app_state(state.clone()));
    // Spaces decode out of `%20` and fail `validate_namespace`.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces?namespace=bad%20ns")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// --- set_namespace_standard_qs --------------------------------------

#[tokio::test]
async fn http_set_namespace_standard_qs_happy_path_creates_placeholder() {
    // Body carries `namespace` (S34 shape, no URL segment). With no
    // federation configured the inner fn auto-seeds a placeholder
    // standard memory and returns 201 CREATED.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(test_app_state(state.clone()));
    let body = json!({"namespace": "qs-set-happy"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["namespace"], "qs-set-happy");
    assert_eq!(v["set"], true);
    assert!(v["standard_id"].is_string());
}

#[tokio::test]
async fn http_set_namespace_standard_qs_missing_namespace_returns_400() {
    // No `namespace` in body and no nested `standard.namespace` —
    // the QS-form set handler bails with 400 before touching the DB.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(test_app_state(state));
    let body = json!({"governance": {"approver": "human"}});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"].as_str().unwrap_or("").contains("namespace"),
        "error must mention the missing namespace, got {v:?}"
    );
}

#[tokio::test]
async fn http_set_namespace_standard_qs_invalid_governance_returns_400() {
    // Pre-seed a real memory we can target by id, so we get past the
    // placeholder branch and into `validate_governance_policy`.
    let state = test_state();
    let mem_id = {
        let lock = state.lock().await;
        let now = Utc::now().to_rfc3339();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "qs-set-bad-policy".into(),
            title: "anchor".into(),
            content: "anchor".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(test_app_state(state));
    // `consensus: 0` is always invalid (validator rejects it).
    let body = json!({
        "namespace": "qs-set-bad-policy",
        "id": mem_id,
        "governance": {
            "approver": {"consensus": 0},
            "write": "approve",
            "promote": "log",
            "delete": "log"
        }
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_set_namespace_standard_qs_nested_standard_payload_works() {
    // S34's body shape nests fields under `standard: { … }`. The
    // QS-form set handler must read either `body.namespace` or
    // `body.standard.namespace`. This exercises the second arm.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(test_app_state(state));
    let body = json!({"standard": {"namespace": "qs-nested-ns"}});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["namespace"], "qs-nested-ns");
}

// --- clear_namespace_standard_qs ------------------------------------

#[tokio::test]
async fn http_clear_namespace_standard_qs_happy_path_after_set() {
    // Set then clear. Clear must return 200 with `{cleared: true|…}`.
    let state = test_state();
    let app_state = test_app_state(state.clone());
    let set_router = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state.clone());
    let _ = set_router
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-clear-happy"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let clear_router = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::delete(clear_namespace_standard_qs),
        )
        .with_state(app_state);
    let resp = clear_router
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces?namespace=qs-clear-happy")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["namespace"], "qs-clear-happy");
}

#[tokio::test]
async fn http_clear_namespace_standard_qs_idempotent_on_unset() {
    // Clearing a namespace that has no standard set is a no-op
    // success (idempotency). The MCP handler returns
    // `{cleared: <bool>, namespace}` rather than 404.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::delete(clear_namespace_standard_qs),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces?namespace=qs-clear-noop")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_clear_namespace_standard_qs_missing_namespace_returns_400() {
    // No `?namespace=…` → 400 BadRequest with an `error` payload that
    // names the missing field.
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::delete(clear_namespace_standard_qs),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"].as_str().unwrap_or("").contains("namespace"),
        "error must mention namespace, got {v:?}"
    );
}

// --- fanout_or_503 / quorum_not_met error matrix --------------------

#[tokio::test]
async fn http_set_qs_fanout_503_when_all_peers_down() {
    // Single peer, W=2 (local + 1 peer required). Peer 500s on every
    // POST → cannot meet quorum → 503 `quorum_not_met` payload.
    let state = test_state();
    let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-fed-down"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[tokio::test]
async fn http_set_qs_fanout_503_payload_shape_includes_quorum_fields() {
    // The 503 body must round-trip through `QuorumNotMetPayload` and
    // surface `error="quorum_not_met"`, `got`, `needed`, `reason`.
    // Single peer down @ W=2 → got=1 (local), needed=2, reason names
    // the failure (unreachable / 500 → "unreachable").
    let state = test_state();
    let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-503-shape"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["error"], "quorum_not_met");
    assert!(v["got"].as_u64().is_some(), "got must be a number");
    assert!(v["needed"].as_u64().is_some(), "needed must be a number");
    assert!(v["reason"].is_string(), "reason must be a string");
    // Local always commits → got >= 1; needed must equal W=2.
    assert_eq!(v["needed"].as_u64().unwrap(), 2);
}

#[tokio::test]
async fn http_set_qs_fanout_503_includes_retry_after_header() {
    // The 503 path returns a `Retry-After: 2` header so clients can
    // back off without parsing the body.
    let state = test_state();
    let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-503-retry-after"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let retry = resp
        .headers()
        .get("retry-after")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    assert_eq!(retry, "2", "503 must include Retry-After: 2");
}

#[tokio::test]
async fn http_set_qs_fanout_quorum_met_with_one_peer_down() {
    // N=3, W=2 (majority). One peer 500s, one peer acks → quorum
    // met → 201 CREATED. Exercises the quorum-not-all-fail success
    // branch of `fanout_or_503` (`Ok(_) => None`).
    let state = test_state();
    let (peer_up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
    let (peer_down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_up, peer_down], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-quorum-met"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn http_set_qs_fanout_quorum_not_met_strict_n_equals_w() {
    // N=2, W=2 (all-or-nothing). Single peer down → 1/2 acks → 503.
    // This is the "strict" all-acks-required posture (W=N).
    let state = test_state();
    let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-strict-quorum"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["needed"].as_u64().unwrap(), 2);
    // got must be < needed in the failure case.
    assert!(v["got"].as_u64().unwrap() < v["needed"].as_u64().unwrap());
}

#[tokio::test]
async fn http_set_qs_fanout_quorum_w_equals_one_any_success_writes_succeed() {
    // W=1 → local commit alone is enough; peer down doesn't 503.
    // This exercises the `K=1` (any-success) row in the matrix.
    let state = test_state();
    let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 1, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-w1-any"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn http_set_qs_fanout_503_when_peer_hangs_past_deadline() {
    // Hanging peer + tight deadline → quorum_not_met with reason
    // "timeout" or "unreachable" (depending on whether the request
    // returned an error before the deadline). Either way → 503.
    let state = test_state();
    let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Hang).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 200);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-hang"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let reason = v["reason"].as_str().unwrap_or("");
    assert!(
        reason == "timeout" || reason == "unreachable",
        "expected timeout/unreachable, got {reason:?}"
    );
}

#[tokio::test]
async fn http_set_qs_fanout_503_when_peer_returns_503() {
    // A peer that itself replies 503 (overloaded) is still a
    // failed ack. The leader's 503 response carries the federation
    // payload, not the peer's. (Smoke-tests that 5xx-class peers
    // beyond just 500 also count as failures.)
    let state = test_state();
    let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail503).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-peer-503"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["error"], "quorum_not_met");
}

#[tokio::test]
async fn http_set_qs_fanout_503_when_peer_returns_4xx() {
    // 4xx from a peer also counts as a failed ack — the federation
    // ack tracker requires a 200 to count toward quorum. (Closes the
    // "200 + 4xx from peers" matrix row.)
    let state = test_state();
    let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail400).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-peer-400"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[tokio::test]
async fn http_set_qs_fanout_503_partition_minority_fails() {
    // N=4 (local + 3 peers), W=3 (majority). Two peers down, one
    // up → can't meet quorum (got = 2, needed = 3) → 503.
    let state = test_state();
    let (up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
    let (down1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let (down2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![up, down1, down2], 3, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-minority"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["needed"].as_u64().unwrap(), 3);
    assert!(v["got"].as_u64().unwrap() < 3);
}

#[tokio::test]
async fn http_set_qs_fanout_majority_tolerates_minority_partition() {
    // N=4, W=3 (majority). Two peers up, one down → quorum met
    // (got = 3 ≥ needed = 3) → 201 CREATED. Mirror of the previous
    // test but with the failure flipped into a success.
    let state = test_state();
    let (up1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
    let (up2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
    let (down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![up1, up2, down], 3, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-majority"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn http_clear_qs_fanout_503_when_peer_down() {
    // The CLEAR path uses `broadcast_namespace_meta_clear_quorum`,
    // a different fanout function from `fanout_or_503`. Both share
    // the QuorumNotMetPayload contract and Retry-After=2 header.
    // This test exercises the clear-side 503 lane.
    let state = test_state();
    // Pre-seed a namespace standard so the clear has something to do.
    // We do this with no federation by using a separate AppState.
    let local_app_state = test_app_state(state.clone());
    let set_router = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(local_app_state);
    let _ = set_router
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-clear-fed"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route(
            "/api/v1/namespaces",
            axum::routing::delete(clear_namespace_standard_qs),
        )
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces?namespace=qs-clear-fed")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let retry = resp
        .headers()
        .get("retry-after")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    assert_eq!(retry, "2", "clear 503 must include Retry-After: 2");
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["error"], "quorum_not_met");
}

#[tokio::test]
async fn http_set_qs_fanout_no_federation_returns_201_without_peers() {
    // No `--quorum-peers` configured → `app.federation` is None →
    // `fanout_or_503` short-circuits to None and the handler returns
    // 201 without any peer involvement. Pins the no-fed branch.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-no-fed"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn http_set_qs_fanout_peer_called_at_least_once_on_quorum_failure() {
    // Even when quorum fails, the leader must have *attempted* to
    // POST to the peer at least once. This guards against the
    // pre-flight short-circuit that would skip the fanout entirely.
    use std::sync::atomic::Ordering;

    let state = test_state();
    let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-fanout-attempt"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    // Wait briefly for any retry to settle so the count is stable.
    for _ in 0..50 {
        if count.load(Ordering::Relaxed) >= 1 {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    assert!(
        count.load(Ordering::Relaxed) >= 1,
        "leader must have attempted the fanout POST at least once"
    );
}

#[tokio::test]
async fn http_set_qs_fanout_peer_receives_post_on_happy_path() {
    // Counterpart to the failure-attempt test: on a happy path,
    // exactly one peer-side POST per fanout completes within a
    // short settle window.
    use std::sync::atomic::Ordering;

    let state = test_state();
    let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
    let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
    let app = Router::new()
        .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
        .with_state(app_state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(
                    serde_json::to_vec(&json!({"namespace": "qs-fanout-happy"})).unwrap(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    // The set path triggers TWO fanout POSTs to each peer: one for
    // the standard memory (`fanout_or_503`) and one for the
    // namespace_meta row (`broadcast_namespace_meta_quorum`). Wait
    // for at least one to land — the second may be background-detached.
    for _ in 0..50 {
        if count.load(Ordering::Relaxed) >= 1 {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    assert!(count.load(Ordering::Relaxed) >= 1);
}

// -------------------------------------------------------------------
// W12-B closer — handlers.rs long-tail sweep
//
// After W8 + W11, handlers.rs sits ~88-90%. The runs below target
// small uncovered chunks scattered across the surface — internal
// helpers (percent_decode_lossy, constant_time_eq), additional middleware
// arms, and HTTP error/happy paths the existing fixture doesn't reach.
// -------------------------------------------------------------------

// ---- percent_decode_lossy / constant_time_eq unit tests ----

#[test]
fn percent_decode_lossy_passes_through_plain_ascii() {
    let s = percent_decode_lossy("hello-world_123");
    assert_eq!(s, "hello-world_123");
}

#[test]
fn percent_decode_lossy_decodes_basic_escape() {
    let s = percent_decode_lossy("a%20b");
    assert_eq!(s, "a b");
}

#[test]
fn percent_decode_lossy_decodes_plus_and_ampersand() {
    // %2B -> '+', %26 -> '&'
    let s = percent_decode_lossy("a%2Bb%26c");
    assert_eq!(s, "a+b&c");
}

#[test]
fn percent_decode_lossy_handles_invalid_hex_passthrough() {
    // %ZZ is not a valid hex escape — emit the bytes verbatim.
    let s = percent_decode_lossy("a%ZZb");
    assert_eq!(s, "a%ZZb");
}

#[test]
fn percent_decode_lossy_handles_truncated_escape() {
    // Trailing `%X` (only one hex char left) — passthrough.
    let s = percent_decode_lossy("a%2");
    assert_eq!(s, "a%2");
    let s2 = percent_decode_lossy("%");
    assert_eq!(s2, "%");
}

#[test]
fn percent_decode_lossy_decodes_full_byte_range() {
    // %FF -> 0xFF; resulting bytes round-trip through utf8_lossy.
    let s = percent_decode_lossy("%41%42%43");
    assert_eq!(s, "ABC");
}

#[test]
fn percent_decode_lossy_empty_input_returns_empty() {
    let s = percent_decode_lossy("");
    assert_eq!(s, "");
}

#[test]
fn constant_time_eq_returns_true_for_equal_bytes() {
    assert!(constant_time_eq(b"hello", b"hello"));
    assert!(constant_time_eq(b"", b""));
}

#[test]
fn constant_time_eq_returns_false_for_different_bytes() {
    assert!(!constant_time_eq(b"hello", b"world"));
}

#[test]
fn constant_time_eq_returns_false_for_different_lengths() {
    assert!(!constant_time_eq(b"a", b"ab"));
    assert!(!constant_time_eq(b"abc", b""));
}

#[test]
fn constant_time_eq_compares_high_bytes_correctly() {
    // 0x80..0xFF range — make sure XOR-or behavior matches.
    let a = [0x80u8, 0x81, 0x82, 0xFF];
    let b = [0x80u8, 0x81, 0x82, 0xFF];
    assert!(constant_time_eq(&a, &b));
    let c = [0x80u8, 0x81, 0x82, 0xFE];
    assert!(!constant_time_eq(&a, &c));
}

// ---- api_key_auth: query-param percent-decoded match ----

#[tokio::test]
async fn api_key_query_param_with_percent_encoded_chars_matches() {
    // Key contains '+' which must be percent-encoded as %2B in the
    // query string. The middleware decodes before comparison
    // (ultrareview #337) so the encoded form must still match.
    let app = auth_app(Some("a+b"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories?api_key=a%2Bb")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn api_key_query_param_wrong_value_rejected() {
    let app = auth_app(Some("secret"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories?api_key=wrong")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn api_key_query_param_with_other_pairs_still_matches() {
    // Non-`api_key=` pairs in the query string don't disturb the
    // match — the middleware iterates pairs and only inspects
    // `api_key=`.
    let app = auth_app(Some("secret"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories?other=val&api_key=secret&trailing=x")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn api_key_header_with_invalid_utf8_falls_through() {
    // Header bytes that aren't valid UTF-8 fail `to_str()` and the
    // middleware moves on to the query check. Without a query match
    // the result is 401.
    let app = auth_app(Some("secret"));
    // HeaderValue::from_bytes accepts all bytes, but to_str rejects non-UTF8.
    let bytes = [0x80u8, 0x81u8];
    let req = axum::http::Request::builder()
        .uri("/api/v1/memories")
        .header(
            "x-api-key",
            axum::http::HeaderValue::from_bytes(&bytes).unwrap(),
        )
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

// ---- /api/v1/health route via Router ----

#[tokio::test]
async fn http_health_route_returns_200_with_status_ok() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/health", axum_get(health))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["status"], "ok");
    assert_eq!(v["service"], "ai-memory");
    // The handler reports embedder_ready and federation_enabled
    // straight from the AppState wiring — both false in this test.
    assert_eq!(v["embedder_ready"], false);
    assert_eq!(v["federation_enabled"], false);
}

// ---- prometheus_metrics happy path ----

#[tokio::test]
async fn http_prometheus_metrics_returns_text_body() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/metrics", axum_get(prometheus_metrics))
        .with_state(state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/metrics")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    // Prometheus exposition starts with a `#` comment line; whatever
    // the renderer emits, we just confirm the body is non-empty.
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    assert!(!bytes.is_empty());
}

// ---- list_namespaces with seeded data ----

#[tokio::test]
async fn http_list_namespaces_returns_seeded_namespaces() {
    let state = test_state();
    let _ = insert_test_memory(&state, "ns-foo", "t1").await;
    let _ = insert_test_memory(&state, "ns-bar", "t2").await;
    let app = Router::new()
        .route("/api/v1/namespaces", axum_get(list_namespaces))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/namespaces")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let ns = v["namespaces"].as_array().expect("namespaces array");
    assert!(!ns.is_empty());
}

// ---- get_taxonomy variants ----

#[tokio::test]
async fn http_get_taxonomy_no_prefix_returns_tree() {
    let state = test_state();
    let _ = insert_test_memory(&state, "tax/a", "t1").await;
    let _ = insert_test_memory(&state, "tax/b", "t2").await;
    let app = Router::new()
        .route("/api/v1/taxonomy", axum_get(get_taxonomy))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/taxonomy")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["tree"].is_array() || v["tree"].is_object());
}

#[tokio::test]
async fn http_get_taxonomy_invalid_prefix_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/taxonomy", axum_get(get_taxonomy))
        .with_state(test_app_state(state.clone()));
    // A namespace prefix that ends with `/` after trimming the
    // trailing `/` and segments (e.g. `foo//bar`) fails
    // validate_namespace on the empty-segment check. The handler
    // first trims the trailing `/`, so to actually fail we need
    // an empty interior segment.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/taxonomy?prefix=foo%2F%2Fbar")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_get_taxonomy_with_depth_and_limit() {
    let state = test_state();
    let _ = insert_test_memory(&state, "tax2/a/b", "t").await;
    let app = Router::new()
        .route("/api/v1/taxonomy", axum_get(get_taxonomy))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/taxonomy?prefix=tax2&depth=4&limit=100")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- get_memory edge cases ----

#[tokio::test]
async fn http_get_memory_invalid_id_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}", axum_get(get_memory))
        .with_state(test_app_state(state));
    // Oversized id (>MAX_ID_LEN=128 bytes) fails validate_id.
    let big = "a".repeat(200);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{big}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_get_memory_unknown_id_returns_404() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}", axum_get(get_memory))
        .with_state(test_app_state(state));
    // 32-char hex never inserted.
    let id = "deadbeefdeadbeefdeadbeefdeadbeef";
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_get_memory_after_insert_returns_payload() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-get", "t-get").await;
    let app = Router::new()
        .route("/api/v1/memories/{id}", axum_get(get_memory))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["memory"]["id"], id);
    assert!(v["links"].is_array());
}

// ---- delete_memory edge cases (no governance, no federation) ----

#[tokio::test]
async fn http_delete_memory_invalid_id_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/{id}",
            axum::routing::delete(delete_memory),
        )
        .with_state(test_app_state(state));
    let big = "b".repeat(200);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{big}"))
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_delete_memory_unknown_id_returns_404() {
    let state = test_state();
    let app = Router::new()
        .route(
            "/api/v1/memories/{id}",
            axum::routing::delete(delete_memory),
        )
        .with_state(test_app_state(state));
    let id = "cafebabecafebabecafebabecafebabe";
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_delete_memory_happy_path_returns_deleted_true() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-del", "t-del").await;
    let app = Router::new()
        .route(
            "/api/v1/memories/{id}",
            axum::routing::delete(delete_memory),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["deleted"], true);
}

#[tokio::test]
async fn http_delete_memory_invalid_x_agent_id_returns_400() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-del-bad", "t").await;
    let app = Router::new()
        .route(
            "/api/v1/memories/{id}",
            axum::routing::delete(delete_memory),
        )
        .with_state(test_app_state(state));
    // Header value with a literal space fails validate_agent_id.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("DELETE")
                .header("x-agent-id", "bad agent id")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- promote_memory edge cases ----

#[tokio::test]
async fn http_promote_memory_invalid_id_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
        .with_state(test_app_state(state));
    let big = "p".repeat(200);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{big}/promote"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_promote_memory_unknown_id_returns_404() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
        .with_state(test_app_state(state));
    let id = "facefacefacefacefacefacefaceface";
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}/promote"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_promote_target_tier_mid_stops_at_mid_keeps_expiry_1623() {
    // #1623 — HTTP parity with the MCP target_tier param (#831): a
    // JSON body {"target_tier":"mid"} stops the bump at mid and
    // PRESERVES the live TTL; pre-fix the route read no body and
    // silently jumped to long.
    let state = test_state();
    let id = {
        let lock = state.lock().await;
        let now = Utc::now();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Short,
            namespace: "ns-promote-1623".into(),
            title: "to-promote-1623".into(),
            content: "content".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.to_rfc3339(),
            updated_at: now.to_rfc3339(),
            last_accessed_at: None,
            expires_at: Some((now + Duration::seconds(crate::SECS_PER_HOUR)).to_rfc3339()),
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };
    let app = Router::new()
        .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}/promote"))
                .method("POST")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"target_tier":"mid"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    {
        let lock = state.lock().await;
        let got = db::get(&lock.0, &id).unwrap().unwrap();
        assert_eq!(got.tier, Tier::Mid, "#1623: must stop at mid");
        assert!(
            got.expires_at.is_some(),
            "#1623: mid landing must keep the live TTL"
        );
    }
    // And the downgrade refusal arm:
    let resp2 = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}/promote"))
                .method("POST")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"target_tier":"short"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp2.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_promote_memory_happy_path_clears_expires_at() {
    let state = test_state();
    // Insert a short-tier memory with expires_at set.
    let id = {
        let lock = state.lock().await;
        let now = Utc::now();
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Short,
            namespace: "ns-promote".into(),
            title: "to-promote".into(),
            content: "content".into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.to_rfc3339(),
            updated_at: now.to_rfc3339(),
            last_accessed_at: None,
            expires_at: Some((now + Duration::seconds(crate::SECS_PER_HOUR)).to_rfc3339()),
            metadata: serde_json::json!({}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap()
    };
    let app = Router::new()
        .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}/promote"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    // Confirm tier=long and expires_at cleared in the DB.
    let lock = state.lock().await;
    let m = db::get(&lock.0, &id).unwrap().unwrap();
    assert_eq!(m.tier, Tier::Long);
    assert!(m.expires_at.is_none());
}

// ---- update_memory edge cases ----

#[tokio::test]
async fn http_update_memory_unknown_id_returns_404() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state));
    let id = "1234567812345678123456781234567a";
    let body = serde_json::json!({"title": "new title"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("PUT")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_update_memory_happy_path_returns_updated_payload() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-upd", "old title").await;
    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({"title": "new title", "content": "new content"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("PUT")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let lock = state.lock().await;
    let m = db::get(&lock.0, &id).unwrap().unwrap();
    assert_eq!(m.title, "new title");
    assert_eq!(m.content, "new content");
}

// ---- create_link / delete_link / get_links happy paths ----

#[tokio::test]
async fn http_create_link_happy_path_returns_201() {
    let state = test_state();
    let src = insert_test_memory(&state, "ns-link", "src").await;
    let tgt = insert_test_memory(&state, "ns-link", "tgt").await;
    let app = Router::new()
        .route("/api/v1/links", axum_post(create_link))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "source_id": src,
        "target_id": tgt,
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["linked"], true);
}

// v0.7.0 fix-campaign A3 (LINK-PARITY, #690) — HTTP create_link
// must refuse a cycle-closing `reflects_on` edge with 409
// CONFLICT. Closes S5-H2: before A3, the cycle gate lived only
// in `mcp/tools/link.rs::handle_link` and the HTTP path could
// land cycles the MCP path would have refused.
#[tokio::test]
async fn http_create_link_refuses_cycle() {
    use crate::config::{
        PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
    };
    let _gate = lock_permissions_mode_for_test();
    override_active_permissions_mode_for_test(PermissionsMode::Off);

    let state = test_state();
    let a = insert_test_memory(&state, "a3-http-cycle", "a").await;
    let b = insert_test_memory(&state, "a3-http-cycle", "b").await;
    // Pre-seed a --reflects_on--> b so b --reflects_on--> a would
    // close the cycle.
    {
        let lock = state.lock().await;
        db::create_link(&lock.0, &a, &b, "reflects_on").unwrap();
    }
    let app = Router::new()
        .route("/api/v1/links", axum_post(create_link))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "source_id": b,
        "target_id": a,
        "relation": "reflects_on",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let err = v["error"].as_str().unwrap_or_default();
    assert!(
        err.starts_with(db::LINK_CYCLE_ERR_PREFIX),
        "expected cycle prefix, got: {err}"
    );
}

// v0.7.0 fix-campaign A3 (LINK-PARITY, #690) — HTTP create_link
// must refuse a write that the K9 permission pipeline denies,
// with 403 FORBIDDEN. Before A3 the K9 gate only ran in the MCP
// handler.
#[tokio::test]
async fn http_create_link_respects_governance() {
    use crate::config::{
        PermissionsMode, lock_permissions_mode_for_test, override_active_permissions_mode_for_test,
    };
    use crate::permissions::{
        PermissionRule, RuleDecision, clear_active_permission_rules_for_test,
        set_active_permission_rules,
    };
    let _gate = lock_permissions_mode_for_test();
    override_active_permissions_mode_for_test(PermissionsMode::Enforce);
    clear_active_permission_rules_for_test();
    set_active_permission_rules(vec![PermissionRule {
        namespace_pattern: "a3-http-gov/**".to_string(),
        op: "memory_link".to_string(),
        agent_pattern: "*".to_string(),
        decision: RuleDecision::Deny,
        reason: Some("test: A3 http governance deny".to_string()),
    }]);

    let state = test_state();
    let src = insert_test_memory(&state, "a3-http-gov/zone", "src").await;
    let tgt = insert_test_memory(&state, "a3-http-gov/zone", "tgt").await;
    let app = Router::new()
        .route("/api/v1/links", axum_post(create_link))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "source_id": src,
        "target_id": tgt,
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let err = v["error"].as_str().unwrap_or_default();
    assert!(
        err.starts_with(db::LINK_PERMISSION_DENIED_ERR_PREFIX),
        "expected permission-denied prefix, got: {err}"
    );

    clear_active_permission_rules_for_test();
    override_active_permissions_mode_for_test(PermissionsMode::Advisory);
}

#[tokio::test]
async fn http_create_link_invalid_link_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/links", axum_post(create_link))
        .with_state(test_app_state(state));
    // self-link is rejected by validate_link
    let body = serde_json::json!({
        "source_id": "abc",
        "target_id": "abc",
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_get_links_invalid_id_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/{id}/links", axum_get(get_links))
        .with_state(test_app_state(state));
    let big = "x".repeat(200);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{big}/links"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_get_links_after_create_returns_link() {
    let state = test_state();
    let src = insert_test_memory(&state, "ns-getlinks", "src").await;
    let tgt = insert_test_memory(&state, "ns-getlinks", "tgt").await;
    {
        let lock = state.lock().await;
        db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
    }
    let app = Router::new()
        .route("/api/v1/memories/{id}/links", axum_get(get_links))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{src}/links"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let links = v["links"].as_array().expect("links array");
    assert!(!links.is_empty());
}

#[tokio::test]
async fn http_delete_link_after_create_returns_deleted_true() {
    let state = test_state();
    let src = insert_test_memory(&state, "ns-dellink", "src").await;
    let tgt = insert_test_memory(&state, "ns-dellink", "tgt").await;
    {
        let lock = state.lock().await;
        db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
    }
    let app = Router::new()
        .route("/api/v1/links", axum::routing::delete(delete_link))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "source_id": src,
        "target_id": tgt,
        "relation": "related_to",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links")
                .method("DELETE")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["deleted"], true);
}

// ---- get_stats / run_gc / export_memories happy paths ----

#[tokio::test]
async fn http_get_stats_with_data_returns_total() {
    let state = test_state();
    let _ = insert_test_memory(&state, "ns-stats", "t1").await;
    let _ = insert_test_memory(&state, "ns-stats", "t2").await;
    let app = Router::new()
        .route("/api/v1/stats", axum_get(get_stats))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/stats")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["total"], 2);
}

#[tokio::test]
async fn http_export_memories_with_data_returns_count() {
    // #957 — endpoint is admin-gated; the count happy path runs
    // through the admin allowlist. Non-admin rejection covered by
    // `tests/export_memories_admin_gate_957.rs`.
    let state = test_state();
    let _ = insert_test_memory(&state, "ns-export", "t1").await;
    let _ = insert_test_memory(&state, "ns-export", "t2").await;
    let app = Router::new()
        .route("/api/v1/export", axum_get(export_memories))
        .with_state(test_app_state_with_admin(state, "ops:admin"));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/export")
                .header("x-agent-id", "ops:admin")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 2);
    assert!(v["exported_at"].is_string());
}

// ---- import_memories happy path ----

#[tokio::test]
async fn http_import_memories_inserts_valid_rows() {
    // #956 — `/api/v1/import` is admin-gated; route through
    // `test_app_state_with_admin` + matching `X-Agent-Id`.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/import", axum_post(import_memories))
        .with_state(test_app_state_with_admin(state, "ops:admin"));
    let now = Utc::now().to_rfc3339();
    let mem = serde_json::json!({
        "id": Uuid::new_v4().to_string(),
        "tier": Tier::Long.as_str(),
        "namespace": "imported",
        "title": "imported-row",
        "content": "imported content",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "import",
        "access_count": 0,
        "created_at": now,
        "updated_at": now,
        "last_accessed_at": null,
        "expires_at": null,
        "metadata": {},
    });
    let body = serde_json::json!({"memories": [mem]});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/import")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ops:admin")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["imported"], 1);
}

// ---- recall edge cases ----

#[tokio::test]
async fn http_recall_get_invalid_as_agent_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/recall", axum_get(recall_memories_get))
        .with_state(test_app_state(state));
    // as_agent goes through validate_namespace which rejects spaces.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall?context=hello&as_agent=bad%20agent")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_recall_post_invalid_as_agent_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/recall", axum_post(recall_memories_post))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"context": "x", "as_agent": "bad agent"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_recall_post_zero_budget_tokens_returns_200() {
    // Phase P6 (R1): budget_tokens=0 returns 200 with an empty
    // memories list — see recall_post_zero_budget_tokens_returns_empty
    // for the matching unit-tested handler-level test.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/recall", axum_post(recall_memories_post))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"context": "x", "budget_tokens": 0});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- search_memories with as_agent invalid ----

#[tokio::test]
async fn http_search_invalid_as_agent_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/search", axum_get(search_memories))
        .with_state(test_app_state(state));
    // validate_namespace rejects spaces.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/search?q=hello&as_agent=bad%20agent")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- forget_memories happy and noop ----

#[tokio::test]
async fn http_forget_memories_with_nothing_to_match_returns_zero() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/forget", axum_post(forget_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"namespace": "no-such-ns"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/forget")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["deleted"], 0);
}

// ---- run_gc happy ----

#[tokio::test]
async fn http_run_gc_after_insert_returns_zero_when_nothing_expired() {
    let state = test_state();
    let _ = insert_test_memory(&state, "gc-ns", "title").await;
    let app = Router::new()
        .route("/api/v1/gc", axum_post(run_gc))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/gc")
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["expired_deleted"], 0);
}

// ---- list_pending limit clamp + happy ----

#[tokio::test]
async fn http_list_pending_default_limit_returns_count_zero_for_empty() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending", axum_get(list_pending))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/pending")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["count"], 0);
}

// ---- restore_archive edge cases (no federation) ----

#[tokio::test]
async fn http_restore_archive_invalid_id_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state));
    let big = "r".repeat(200);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{big}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_restore_archive_unknown_id_returns_404() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state));
    let id = "0123456701234567012345670123456a";
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn http_restore_archive_happy_path_returns_restored_true() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-restore", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/archive/{id}/restore"))
                .method("POST")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["restored"], true);
}

// ---- entity_get_by_alias edge cases ----

#[tokio::test]
async fn http_entity_get_by_alias_with_namespace_filter_returns_found_false() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/entities/by_alias", axum_get(entity_get_by_alias))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities/by_alias?alias=Acme&namespace=corp")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["found"], false);
}

// ---- kg_timeline returns_empty_for_unlinked_source covered, add since/until variants ----

#[tokio::test]
async fn http_kg_timeline_with_valid_since_and_until_succeeds() {
    let state = test_state();
    let id = insert_test_memory(&state, "kg-tl", "src").await;
    let app = Router::new()
        .route("/api/v1/kg/timeline", axum_get(kg_timeline))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!(
                    "/api/v1/kg/timeline?source_id={id}&since=2020-01-01T00:00:00Z&until=2030-01-01T00:00:00Z&limit=100"
                ))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- session_start happy path ----

#[tokio::test]
async fn http_session_start_with_namespace_returns_session_id() {
    let state = test_state();
    let _ = insert_test_memory(&state, "session-ns", "row").await;
    let app = Router::new()
        .route("/api/v1/session/start", axum_post(session_start))
        .with_state(state);
    let body = serde_json::json!({"namespace": "session-ns", "limit": 5, "agent_id": "ai:tester"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/session/start")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["session_id"].is_string());
    assert_eq!(v["agent_id"], "ai:tester");
}

// ---- notify rejects empty payload+content ----

#[tokio::test]
async fn http_notify_missing_payload_and_content_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "target_agent_id": "ai:bob",
        "title": "ping",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header("x-agent-id", "ai:alice")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_notify_with_payload_field_returns_201() {
    let state = test_state();
    // Pre-register sender so the inbox handler accepts the write.
    {
        let lock = state.lock().await;
        db::register_agent(&lock.0, "ai:alice", "ai:human", &[]).unwrap();
        db::register_agent(&lock.0, "ai:bob", "ai:human", &[]).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/notify", axum_post(notify))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "target_agent_id": "ai:bob",
        "title": "ping",
        "payload": "hi bob",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/notify")
                .method("POST")
                .header("x-agent-id", "ai:alice")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

// ---- subscribe / unsubscribe / list_subscriptions edge cases ----

#[tokio::test]
async fn http_subscribe_missing_url_and_namespace_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscribe", axum_post(subscribe))
        .with_state(test_app_state(state));
    // Neither url nor namespace — handler rejects.
    // #901: matching X-Agent-Id required for body.agent_id.
    let body = serde_json::json!({"agent_id": "ai:alice"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_subscribe_with_namespace_synthesizes_loopback_url_and_returns_201() {
    // R3-S1.HMAC (2026-05-13): subscribe requires per-sub or
    // server-wide HMAC secret.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscribe", axum_post(subscribe))
        .with_state(test_app_state(state));
    // #901: matching X-Agent-Id required for body.agent_id.
    let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice", "secret": "ns-test-secret"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["namespace"], "team/alice");
    assert_eq!(v["agent_id"], "ai:alice");
}

#[tokio::test]
async fn http_unsubscribe_missing_id_and_namespace_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
        .with_state(test_app_state(state));
    // x-agent-id header set; but neither id nor namespace — 400.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe")
                .method("DELETE")
                .header("x-agent-id", "ai:alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_unsubscribe_by_agent_namespace_after_subscribe_returns_removed() {
    // R3-S1.HMAC (2026-05-13): subscribe requires HMAC secret.
    let state = test_state();
    // Subscribe via the handler so the row lands consistent with the
    // unsubscribe lookup.
    let sub_app = Router::new()
        .route("/api/v1/subscribe", axum_post(subscribe))
        .with_state(test_app_state(state.clone()));
    // #901: matching X-Agent-Id required for body.agent_id.
    let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice", "secret": "ns-test-secret"});
    let resp = sub_app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    let app = Router::new()
        .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe?agent_id=ai:alice&namespace=team/alice")
                .method("DELETE")
                // #870 / #874 (security-high/medium) — DELETE is
                // owner-scoped; authenticated caller comes from header.
                .header("x-agent-id", "ai:alice")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["removed"], true);
}

// ---- list_subscriptions baseline ----

#[tokio::test]
async fn http_list_subscriptions_returns_subscription_rows() {
    // R3-S1.HMAC (2026-05-13): subscribe requires HMAC secret.
    let state = test_state();
    // Drop one subscription via the subscribe handler.
    let sub_app = Router::new()
        .route("/api/v1/subscribe", axum_post(subscribe))
        .with_state(test_app_state(state.clone()));
    // #901: matching X-Agent-Id required for body.agent_id.
    let body = serde_json::json!({"agent_id": "ai:carol", "namespace": "team/carol", "secret": "ns-test-secret"});
    let resp = sub_app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:carol")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    let app = Router::new()
        .route("/api/v1/subscriptions", axum_get(list_subscriptions))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscriptions")
                // #872 (security-high) — listing is owner-scoped to the
                // authenticated header; provide it so the seeded
                // `ai:carol` row is returned.
                .header("x-agent-id", "ai:carol")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["count"].as_u64().unwrap() >= 1);
}

// ---- kg_query happy path with results ----

#[tokio::test]
async fn http_kg_query_after_create_link_returns_node() {
    let state = test_state();
    let src = insert_test_memory(&state, "kg-q", "src").await;
    let tgt = insert_test_memory(&state, "kg-q", "tgt").await;
    {
        let lock = state.lock().await;
        db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
    }
    let app = Router::new()
        .route("/api/v1/kg/query", axum_post(kg_query))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({"source_id": src, "max_depth": 1, "limit": 10});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["source_id"], src);
    let mems = v["memories"].as_array().expect("memories array");
    assert!(!mems.is_empty());
}

#[tokio::test]
async fn http_kg_invalidate_round_trip_marks_link() {
    let state = test_state();
    let src = insert_test_memory(&state, "kg-inv", "src").await;
    let tgt = insert_test_memory(&state, "kg-inv", "tgt").await;
    {
        let lock = state.lock().await;
        db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
    }
    let app = Router::new()
        .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "source_id": src,
        "target_id": tgt,
        "relation": "related_to",
        "valid_until": "2030-01-01T00:00:00Z",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/kg/invalidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["found"], true);
}

// ---- list_archive happy with seeded data ----

#[tokio::test]
async fn http_list_archive_returns_archived_rows() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-archive", "row").await;
    {
        let lock = state.lock().await;
        db::archive_memory(&lock.0, &id, Some("test")).unwrap();
    }
    let app = Router::new()
        .route("/api/v1/archive", axum_get(list_archive))
        .with_state(test_app_state(state.clone()));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive?namespace=ns-archive&limit=10&offset=0")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["count"].as_u64().unwrap() >= 1);
}

// ---- archive_by_ids with reason field ----

#[tokio::test]
async fn http_archive_by_ids_with_explicit_reason_records_it() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-arch", "row").await;
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"ids": [id], "reason": "user requested"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["reason"], "user requested");
    assert_eq!(v["count"], 1);
}

// ---- sync_push: per-field oversize rejections (sweep all guards) ----

fn over_max_string_vec(n: usize) -> Vec<String> {
    (0..n).map(|i| format!("id-{i:040}")).collect()
}

#[tokio::test]
async fn http_sync_push_oversize_deletions_returns_400() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
        "deletions": over_max_string_vec(MAX_BULK_SIZE + 1),
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"]
            .as_str()
            .unwrap()
            .contains("deletions per request"),
        "{v:?}"
    );
}

#[tokio::test]
async fn http_sync_push_oversize_archives_returns_400() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
        "archives": over_max_string_vec(MAX_BULK_SIZE + 1),
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("archives"));
}

#[tokio::test]
async fn http_sync_push_oversize_restores_returns_400() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
        "restores": over_max_string_vec(MAX_BULK_SIZE + 1),
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("restores"));
}

#[tokio::test]
async fn http_sync_push_oversize_namespace_meta_clears_returns_400() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
        "namespace_meta_clears": over_max_string_vec(MAX_BULK_SIZE + 1),
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        v["error"]
            .as_str()
            .unwrap()
            .contains("namespace_meta_clears")
    );
}

#[tokio::test]
async fn http_sync_push_invalid_sender_agent_id_returns_400() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    // Spaces aren't valid agent ids.
    let body = serde_json::json!({
        "sender_agent_id": "bad agent id",
        "memories": [],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("sender_agent_id"));
}

#[tokio::test]
async fn http_sync_push_invalid_x_agent_id_header_returns_400() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "bad agent id")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- sync_push: applies pending decisions and namespace_meta paths ----

#[tokio::test]
async fn http_sync_push_pending_invalid_id_skipped() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let bad_id = "x".repeat(200); // exceeds MAX_ID_LEN
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
        "pendings": [{
            "id": bad_id,
            "action_type": "store",
            "memory_id": null,
            "namespace": "ns",
            "payload": {},
            "requested_by": "ai:peer",
            "requested_at": "2024-01-01T00:00:00Z",
            "status": "pending",
            "approvals": [],
        }],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["skipped"], 1);
    assert_eq!(v["pendings_applied"], 0);
}

#[tokio::test]
async fn http_sync_push_links_invalid_id_skipped() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    // Self-link is invalid via validate_link.
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
        "links": [{
            "source_id": "abc",
            "target_id": "abc",
            "relation": "related_to",
            "created_at": "2024-01-01T00:00:00Z",
        }],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["skipped"], 1);
    assert_eq!(v["links_applied"], 0);
}

#[tokio::test]
async fn http_sync_push_dry_run_links_no_apply() {
    // #1789 — downstream-intent test; opt back to permissive enrollment.
    let _fed = PermissiveFedEnv::new();
    let state = test_state();
    let src = insert_test_memory(&state, "dryrun-links", "src").await;
    let tgt = insert_test_memory(&state, "dryrun-links", "tgt").await;
    let app = Router::new()
        .route("/api/v1/sync/push", axum_post(sync_push))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "sender_agent_id": "ai:peer",
        "memories": [],
        "links": [{
            "source_id": src,
            "target_id": tgt,
            "relation": "related_to",
            "created_at": "2024-01-01T00:00:00Z",
        }],
        "dry_run": true,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/sync/push")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["links_applied"], 0);
    assert_eq!(v["dry_run"], true);
}

// ---- consolidate_memories validation: tier=short clamps title ----

#[tokio::test]
async fn http_consolidate_invalid_title_returns_400() {
    let state = test_state();
    let id1 = insert_test_memory(&state, "ns-cons", "a").await;
    let id2 = insert_test_memory(&state, "ns-cons", "b").await;
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "ids": [id1, id2],
        "title": "",
        "summary": "Summary text",
        "namespace": "ns-cons",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:tester")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- bulk_create empty body returns 200 with zero ----

#[tokio::test]
async fn http_bulk_create_zero_body_returns_zero_created() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories/bulk", axum_post(bulk_create))
        .with_state(test_app_state(state));
    let body: Vec<serde_json::Value> = Vec::new();
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories/bulk")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["created"], 0);
}

// ---- entity_register: blank canonical_name skips validation ----

#[tokio::test]
async fn http_entity_register_with_x_agent_id_header_succeeds() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/entities", axum_post(entity_register))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "canonical_name": "Acme Inc",
        "namespace": "corp",
        "aliases": ["acme", "ACME"],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:tester")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["created"], true);
    assert_eq!(v["canonical_name"], "Acme Inc");
}

// ---- inbox: blank query without header returns BAD_REQUEST? ----

#[tokio::test]
async fn http_get_inbox_without_caller_uses_anonymous_default() {
    // No x-agent-id header, no agent_id query param. The handler
    // resolves to an anonymous identity and returns OK with an
    // empty inbox.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/inbox", axum_get(get_inbox))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/inbox")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- approve_pending invalid x-agent-id ----

#[tokio::test]
async fn http_approve_pending_with_bad_header_agent_id_returns_400() {
    // S5-C1 (2026-05-13): /approve is HMAC-gated; sign so the body
    // reaches agent-id validation.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
        .with_state(test_app_state(state));
    let id = "abcdef0123456789abcdef0123456789";
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{id}/approve"))
                .method("POST")
                .header("x-agent-id", "bad agent id")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- reject_pending invalid x-agent-id ----

#[tokio::test]
async fn http_reject_pending_with_bad_header_agent_id_returns_400() {
    // S5-C1 (2026-05-13): /reject is HMAC-gated; sign so the body
    // reaches agent-id validation.
    let _g = APPROVE_HMAC_TEST_LOCK
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    crate::config::set_active_hooks_hmac_secret(Some("a1-test-secret".to_string()));
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
        .with_state(test_app_state(state));
    let id = "abcdef0123456789abcdef0123456789";
    let (ts, sig) = sign_approve_body("a1-test-secret", "POST", &id, b"");
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/pending/{id}/reject"))
                .method("POST")
                .header("x-agent-id", "bad agent id")
                .header("x-ai-memory-timestamp", &ts)
                .header("x-ai-memory-signature", &sig)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    crate::config::set_active_hooks_hmac_secret(None);
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- create_memory invalid x-agent-id header ----

#[tokio::test]
async fn http_create_memory_invalid_x_agent_id_header_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "t",
        "content": "c",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "bad agent id")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("agent_id"));
}

/// L11 (v0.7.0.1) — `metadata.agent_id` must be honoured as an
/// explicit-caller source in the HTTP precedence chain, matching the
/// MCP path (`crate::mcp::handle_store` (NHI precedence)) and the CLAUDE.md §Agent Identity
/// contract.
///
/// Regression scenario (NHI-D-fed-agentid-mutation): a peer reposts a
/// federated memory through `POST /api/v1/memories` carrying
/// `metadata.agent_id="ai:alice@plan-c"` without a top-level
/// `agent_id` field or `X-Agent-Id` header. Pre-fix, the handler
/// resolved to `anonymous:req-<uuid>` and silently overwrote alice's
/// claim — breaking the immutable-provenance contract enforced at the
/// SQL layer for already-persisted rows.
/// Post-#907 (security-high, 2026-05-19): `metadata.agent_id` is no
/// longer accepted as an AUTHENTICATION source on the HTTP write
/// path — the `X-Agent-Id` header is authoritative. The caller-
/// supplied `metadata.agent_id` is treated as a CLAIM that must
/// MATCH the header-resolved caller (else 403).
#[tokio::test]
async fn l11_create_memory_rejects_metadata_agent_id_mismatch_post_907() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state.clone()));

    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "l11-agentid",
        "title": "L11 agent_id from metadata",
        "content": "Caller stamped agent_id only inside metadata.",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {"agent_id": "ai:alice@plan-c"}
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:bob@plan-c")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "post-#907: metadata.agent_id disagreeing with X-Agent-Id must 403"
    );
}

// ---- create_memory rejects invalid scope ----

#[tokio::test]
async fn http_create_memory_invalid_scope_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));
    // scope must be one of the recognised tokens; gibberish fails
    // validate_scope.
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "test",
        "title": "t",
        "content": "c",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {},
        "scope": "not-a-valid-scope-token"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- list_memories invalid agent_id filter ----

#[tokio::test]
async fn http_list_memories_invalid_agent_id_filter_returns_400() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_get(list_memories))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories?agent_id=bad%20id")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- check_duplicate with no embedder + namespace=blank-trimmed ----

#[tokio::test]
async fn http_check_duplicate_blank_namespace_treated_as_none() {
    // namespace is " " — trimmed to empty, treated as None — handler
    // proceeds and 503s on missing embedder rather than 400.
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/check_duplicate", axum_post(check_duplicate))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"title": "t", "content": "c", "namespace": "   "});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/check_duplicate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

// ---- archive_by_ids: missing reason field defaults to "archive" ----
// (Validates default-string path; existing test covers the default
// path implicitly but we add an explicit body shape.)

#[tokio::test]
async fn http_archive_by_ids_with_no_reason_defaults_to_archive() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-arch-default", "row").await;
    let app = Router::new()
        .route("/api/v1/archive", axum_post(archive_by_ids))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"ids": [id]});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["reason"], "archive");
}

// ---- Governance Pending paths for create/delete/promote ----
//
// These set up an `approve` write/delete/promote policy on a namespace
// standard so the corresponding handler hits the
// `GovernanceDecision::Pending` arm — exercising the queue+202 response
// path that the federation-disabled tests cannot otherwise reach.

/// v0.7.0 K3 — pin the process-wide governance gate to
/// [`crate::config::PermissionsMode::Enforce`] so the suite's
/// historical Pending/Deny assertions still drive the strict
/// path. The K3 work flipped the v0.7.0 default to `Advisory`
/// (log + Allow) so upgrading operators do not get
/// surprise-blocked. These HTTP scenarios test the strict gate
/// behavior so they opt into Enforce explicitly.
///
/// Returns the central gate-mode Mutex guard. Hold it for the
/// duration of the test so no parallel test (in any module) can
/// flip the gate out from under this scenario. The lock is
/// process-wide because the active mode lives in a process-wide
/// atomic.
fn pin_governance_enforce_for_test() -> std::sync::MutexGuard<'static, ()> {
    let guard = crate::config::lock_permissions_mode_for_test();
    crate::config::override_active_permissions_mode_for_test(
        crate::config::PermissionsMode::Enforce,
    );
    guard
}

/// Seed a `_namespace_standard` memory with the supplied governance
/// policy and wire `namespace_meta` to it. Returns nothing — caller
/// just queries the namespace afterward.
async fn seed_governance_policy(state: &Db, ns: &str, policy: serde_json::Value) {
    let lock = state.lock().await;
    let now = Utc::now().to_rfc3339();
    let standard = Memory {
        id: Uuid::new_v4().to_string(),
        tier: Tier::Long,
        namespace: ns.into(),
        title: format!("_standard:{ns}"),
        content: format!("standard for {ns}"),
        tags: vec!["_namespace_standard".to_string()],
        priority: 5,
        confidence: 1.0,
        source: "test".into(),
        access_count: 0,
        created_at: now.clone(),
        updated_at: now,
        last_accessed_at: None,
        expires_at: None,
        metadata: serde_json::json!({
            "agent_id": "ai:owner",
            "governance": policy,
        }),
        reflection_depth: 0,
        memory_kind: crate::models::MemoryKind::Observation,
        entity_id: None,
        persona_version: None,
        citations: Vec::new(),
        source_uri: None,
        source_span: None,
        confidence_source: crate::models::ConfidenceSource::CallerProvided,
        confidence_signals: None,
        confidence_decayed_at: None,
        version: 1,
        lifecycle_state: crate::models::LifecycleState::Open,
    };
    let standard_id = db::insert(&lock.0, &standard).unwrap();
    db::set_namespace_standard(&lock.0, ns, &standard_id, None).unwrap();
}

#[tokio::test]
async fn http_create_memory_governance_pending_returns_202() {
    let _gate = pin_governance_enforce_for_test();
    let state = test_state();
    seed_governance_policy(
        &state,
        "gov-create",
        serde_json::json!({
            "write": "approve",
            "delete": "owner",
            "promote": "any",
            "approver": "human",
        }),
    )
    .await;
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "gov-create",
        "title": "queued",
        "content": "should be queued, not stored",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {},
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:caller")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::ACCEPTED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["status"], "pending");
    assert_eq!(v["action"], "store");
    assert!(v["pending_id"].is_string());
}

#[tokio::test]
async fn http_create_memory_governance_deny_returns_403() {
    let _gate = pin_governance_enforce_for_test();
    // write: registered → unregistered caller is denied without queueing.
    let state = test_state();
    seed_governance_policy(
        &state,
        "gov-deny",
        serde_json::json!({"write": "registered", "approver": "human"}),
    )
    .await;
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "gov-deny",
        "title": "rejected",
        "content": "rejected content",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {},
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:unregistered")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(v["error"].as_str().unwrap().contains("governance"));
}

#[tokio::test]
async fn http_delete_memory_governance_pending_returns_202() {
    let _gate = pin_governance_enforce_for_test();
    let state = test_state();
    seed_governance_policy(
        &state,
        "gov-delete",
        serde_json::json!({
            "write": "any",
            "delete": "approve",
            "promote": "any",
            "approver": "human",
        }),
    )
    .await;
    let id = insert_test_memory(&state, "gov-delete", "to-delete").await;
    let app = Router::new()
        .route(
            "/api/v1/memories/{id}",
            axum::routing::delete(delete_memory),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("DELETE")
                .header("x-agent-id", "ai:caller")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::ACCEPTED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["status"], "pending");
    assert_eq!(v["action"], "delete");
    assert_eq!(v["memory_id"], id);
}

#[tokio::test]
async fn http_delete_memory_governance_deny_returns_403() {
    let _gate = pin_governance_enforce_for_test();
    let state = test_state();
    seed_governance_policy(
        &state,
        "gov-delete-deny",
        serde_json::json!({"write": "any", "delete": "owner", "approver": "human"}),
    )
    .await;
    // The seeded memory's owner is "ai:owner" (set by insert_test_memory's
    // default empty metadata, but here we want a different owner so the
    // current caller fails the owner check). insert_test_memory writes
    // metadata={} so the row has no agent_id → caller "ai:other" cannot
    // pass the owner check (memory_owner=None means deny).
    let id = insert_test_memory(&state, "gov-delete-deny", "row").await;
    let app = Router::new()
        .route(
            "/api/v1/memories/{id}",
            axum::routing::delete(delete_memory),
        )
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("DELETE")
                .header("x-agent-id", "ai:other")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn http_promote_memory_governance_pending_returns_202() {
    let _gate = pin_governance_enforce_for_test();
    let state = test_state();
    seed_governance_policy(
        &state,
        "gov-promote",
        serde_json::json!({
            "write": "any",
            "delete": "any",
            "promote": "approve",
            "approver": "human",
        }),
    )
    .await;
    let id = insert_test_memory(&state, "gov-promote", "to-promote").await;
    let app = Router::new()
        .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}/promote"))
                .method("POST")
                .header("x-agent-id", "ai:caller")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::ACCEPTED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["status"], "pending");
    assert_eq!(v["action"], "promote");
    assert_eq!(v["memory_id"], id);
}

// ---- create_memory contradiction-check happy path with metadata scope ----

#[tokio::test]
async fn http_create_memory_with_top_level_scope_succeeds() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state));
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "scoped",
        "title": "with scope",
        "content": "scoped content",
        "tags": [],
        "priority": 5,
        "confidence": 1.0,
        "source": "api",
        "metadata": {},
        "scope": "private"
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

// ---- create_memory clamps priority/confidence ----

#[tokio::test]
async fn http_create_memory_clamps_extreme_priority_to_range() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memories", axum_post(create_memory))
        .with_state(test_app_state(state.clone()));
    // priority=15 is an attempted overflow but validate_create
    // rejects out-of-range so we use 10 (max) which clamps to 10.
    let body = serde_json::json!({
        "tier": Tier::Long.as_str(),
        "namespace": "clamp",
        "title": "clamp",
        "content": "c",
        "tags": [],
        "priority": 10,
        "confidence": 1.0,
        "source": "api",
        "metadata": {},
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memories")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    // Verify priority preserved at the max.
    let lock = state.lock().await;
    let rows = db::list(
        &lock.0,
        Some("clamp"),
        None,
        10,
        0,
        None,
        None,
        None,
        None,
        None,
    )
    .unwrap();
    assert_eq!(rows[0].priority, 10);
}

// ---- update_memory invalid update body validation ----

#[tokio::test]
async fn http_update_memory_with_oversized_title_returns_400() {
    let state = test_state();
    let id = insert_test_memory(&state, "ns-bigtitle", "old").await;
    let app = Router::new()
        .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
        .with_state(test_app_state(state));
    // title length cap is enforced via validate_update → validate_title.
    let big_title = "T".repeat(10_000);
    let body = serde_json::json!({"title": big_title});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri(format!("/api/v1/memories/{id}"))
                .method("PUT")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---- delete_memory invalid id length too long via header agent ----

#[tokio::test]
async fn http_purge_archive_no_query_returns_purged_zero_for_empty_archive() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/archive", axum::routing::delete(purge_archive))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/archive")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["purged"], 0);
}

// ---- detect_contradictions: invalid topic only (no namespace) accepted ----

#[tokio::test]
async fn http_contradictions_topic_only_returns_ok_empty() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/contradictions", axum_get(detect_contradictions))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/contradictions?topic=missing-topic")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---- entity_register collision (kind != entity) ----

#[tokio::test]
async fn http_entity_register_aliases_with_blanks_filtered() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/entities", axum_post(entity_register))
        .with_state(test_app_state(state.clone()));
    let body = serde_json::json!({
        "canonical_name": "Globex",
        "namespace": "corp2",
        "aliases": ["", "globex", "  ", "GLOBEX"],
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/entities")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

// ---- subscribe with explicit URL form ----

#[tokio::test]
async fn http_subscribe_with_explicit_url_succeeds() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscribe", axum_post(subscribe))
        .with_state(test_app_state(state));
    // #901: matching X-Agent-Id required for body.agent_id.
    let body = serde_json::json!({
        "agent_id": "ai:webhook-user",
        "url": "http://localhost:9999/webhook",
        "events": "store",
        "secret": "shhh",
        "namespace_filter": "team",
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:webhook-user")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["url"], "http://localhost:9999/webhook");
    assert_eq!(v["events"], "store");
}

// ---- unsubscribe by id directly through MCP path ----

#[tokio::test]
async fn http_unsubscribe_by_unknown_id_returns_ok_unchanged() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
        .with_state(test_app_state(state));
    // id=<bogus> path delegates to handle_unsubscribe which returns
    // Ok with `removed: false`.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/subscribe?id=does-not-exist")
                .method("DELETE")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    // Unknown id maps to Ok inside handle_unsubscribe with removed=false.
    // The handler always responds 200 from the Ok arm.
    assert!(
        resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST,
        "got {}",
        resp.status()
    );
}

// --- v0.7.0 B3: family-descriptor embeddings ------------------------

#[test]
fn b3_family_descriptors_cover_all_eight_families_in_order() {
    // Source-anchored to Family::all() so a future re-ordering or
    // family addition trips this test before it ships a stale
    // descriptor list.
    let descriptors = family_descriptors();
    assert_eq!(
        descriptors.len(),
        Family::all().len(),
        "family_descriptors must cover every Family variant"
    );
    for (i, family) in Family::all().iter().enumerate() {
        assert_eq!(
            descriptors[i].0, *family,
            "family_descriptors[{i}] should be {family:?} to match Family::all()"
        );
        assert!(
            !descriptors[i].1.trim().is_empty(),
            "descriptor for {family:?} must be non-empty",
        );
    }
}

#[test]
fn b3_precompute_with_no_embedder_returns_empty() {
    let cache = AppState::precompute_family_embeddings(None);
    assert!(
        cache.is_empty(),
        "no-embedder boot must produce an empty cache",
    );
}

#[test]
fn b3_precompute_with_local_embedder_populates_eight_descriptors() {
    // Boot with a real local embedder when the model is downloadable
    // in this environment; otherwise skip — keeps CI green on
    // sandboxed runners that block hf-hub.
    let Ok(embedder) = Embedder::new_local() else {
        eprintln!(
            "b3_precompute_with_local_embedder_populates_eight_descriptors: \
             Embedder::new_local() failed (likely sandboxed network); skipping",
        );
        return;
    };
    let cache =
        AppState::precompute_family_embeddings(Some(&embedder as &dyn crate::embeddings::Embed));
    assert_eq!(
        cache.len(),
        8,
        "embedder-available boot must produce 8 family-descriptor embeddings",
    );
    let dim = embedder.dim();
    for (family, vec) in &cache {
        assert_eq!(
            vec.len(),
            dim,
            "descriptor vector for {family:?} must match embedder dim",
        );
    }
}

#[test]
fn b3_best_family_match_returns_none_when_cache_empty() {
    let state = test_state();
    let app = test_app_state(state); // family_embeddings is empty here
    assert!(app.best_family_match("store a memory").is_none());
}

// ------------------------------------------------------------------
// v0.7.0 L6 — `/api/v1/auto_tag` and `/api/v1/expand_query` wiring
// ------------------------------------------------------------------
//
// S51 (autonomous-tier LLM surface) hits these endpoints. Without
// an LLM wired the handlers must return 503 with the canonical
// `{error:"LLM not configured"}` envelope — that's the contract
// that lets S51's HTTP-status assertion (`http_code != 200`) emit
// a clean diagnostic instead of "connection refused" / "404".

#[tokio::test]
async fn http_auto_tag_route_returns_503_when_no_llm_l6() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/auto_tag", axum_post(auto_tag_handler))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"title": "OKR review", "content": "Quarterly OKR review notes"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/auto_tag")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["error"], "LLM not configured");
}

#[tokio::test]
async fn http_expand_query_route_returns_503_when_no_llm_l6() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/expand_query", axum_post(expand_query_handler))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"query": "team velocity"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/expand_query")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["error"], "LLM not configured");
}

// ------------------------------------------------------------------
// v0.7.0 L7 — consolidate no longer 422's on absent `summary`
// ------------------------------------------------------------------
//
// Before L7, `ConsolidateBody.summary` was `String` (required), so
// axum's `Json<T>` extractor rejected S51's `{use_llm: true}` body
// with 422 UNPROCESSABLE ENTITY. The fix made `summary` optional
// and synthesises a deterministic fallback when the LLM is absent.
// The 2xx assertion guards against the regression returning.

#[tokio::test]
async fn http_consolidate_accepts_use_llm_without_summary_l7() {
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    let (id_a, id_b) = {
        let lock = state.lock().await;
        let mk = |title: &str, content: &str| Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "l7-no-summary".into(),
            title: title.into(),
            content: content.into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "alice"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let a = db::insert(&lock.0, &mk("aom101-0", "first")).unwrap();
        let b = db::insert(&lock.0, &mk("aom101-1", "second")).unwrap();
        (a, b)
    };
    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state.clone()));

    // S51's exact shape: ids + title + namespace + use_llm, no summary.
    let body = serde_json::json!({
        "ids": [id_a, id_b],
        "title": "AOM-101 lifecycle",
        "namespace": "l7-no-summary",
        "use_llm": true,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    // The regression manifests as 422; the fix produces 201.
    assert_ne!(
        resp.status(),
        StatusCode::UNPROCESSABLE_ENTITY,
        "L7 regression: consolidate 422'd on absent summary"
    );
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    // S51 reads `summary_len >= 20`; assert the body carries a
    // summary string (the LLM-absent fallback is well above 20
    // chars for any 2-id input).
    let summary = v["summary"].as_str().expect("summary in response");
    assert!(
        summary.len() >= 20,
        "L7 fallback summary too short: {summary:?}"
    );
}

// ------------------------------------------------------------------
// v0.7.0 L7-followup — consolidate response carries the materialised
// summary on every key S51 reads
// ------------------------------------------------------------------
//
// R2 cert showed `summary_len=0` on S51 even after L7 made `summary`
// optional and synthesised a deterministic fallback. Root cause: the
// S51 reader at `scenarios/51_autonomous_tier_suite.py:140-145` is
//
//     summary = (
//         cbody.get("summary") or cbody.get("content") or
//         (cbody.get("memory") or {}).get("content")
//         if isinstance(cbody.get("memory"), dict) else ""
//     ) or ""
//
// Python's `if/else` ternary binds tighter than `or`, so the whole
// expression collapses to `""` when `cbody.get("memory")` is not a
// dict — which it wasn't, because the HTTP handler emitted just
// `{id, consolidated, summary}`. The L7-followup fix mirrors `summary`
// into `content` and into a nested `memory.content` so every branch
// of the reader's expression resolves to the same non-empty string.
//
// This test asserts the wire shape S51 needs, by reproducing the
// exact reader logic against the response body.

#[tokio::test]
async fn http_consolidate_response_carries_summary_on_every_key_s51_reads() {
    let state = test_state();
    let now = Utc::now().to_rfc3339();
    let (id_a, id_b) = {
        let lock = state.lock().await;
        let mk = |title: &str, content: &str| Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "l7-followup".into(),
            title: title.into(),
            content: content.into(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now.clone(),
            last_accessed_at: None,
            expires_at: None,
            metadata: serde_json::json!({"agent_id": "alice"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let a = db::insert(
            &lock.0,
            &mk(
                "aom101-0",
                "Engineering filed JIRA AOM-101 to harden the sync_push retry path.",
            ),
        )
        .unwrap();
        let b = db::insert(
            &lock.0,
            &mk(
                "aom101-1",
                "AOM-101 follow-up: added exponential backoff + jitter in retry loop.",
            ),
        )
        .unwrap();
        (a, b)
    };

    let app = Router::new()
        .route("/api/v1/consolidate", axum_post(consolidate_memories))
        .with_state(test_app_state(state.clone()));

    // S51's exact request shape — `use_llm: true` and no `summary`
    // field. test_state() does not wire an LLM, so the resolver
    // falls through to the deterministic concat-of-titles
    // fallback, exactly as the postgres branch will on a daemon
    // whose Ollama endpoint is down.
    let body = serde_json::json!({
        "ids": [id_a, id_b],
        "title": "AOM-101 lifecycle",
        "namespace": "l7-followup",
        "use_llm": true,
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/consolidate")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", "ai:alice")
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    // 1) Top-level `summary` — the obvious key.
    let summary_field = v["summary"].as_str().expect("summary in response");
    assert!(
        summary_field.len() >= 20,
        "L7-followup: summary field too short: {summary_field:?}"
    );

    // 2) Top-level `content` — mirrors `summary` for clients that
    //    treat the consolidated row as a memory.
    let content_field = v["content"].as_str().expect("content in response");
    assert_eq!(content_field, summary_field);

    // 3) Nested `memory.content` — the field S51's ternary
    //    branches into when `memory` is a dict.
    let memory_obj = v["memory"]
        .as_object()
        .expect("response must include a memory object so S51's ternary takes the truthy branch");
    let memory_content = memory_obj["content"]
        .as_str()
        .expect("memory.content must be a string");
    assert_eq!(memory_content, summary_field);
    assert!(memory_obj.contains_key("id"));
    assert!(memory_obj.contains_key("title"));

    // 4) Reproduce S51's exact reader to lock the contract.
    //    Python: `(A or B or C) if D else ""`
    //    Rust   : if D then A.or(B).or(C) else ""
    let cbody = &v;
    let memory_is_dict = cbody
        .get("memory")
        .is_some_and(serde_json::Value::is_object);
    let s51_summary: String = if memory_is_dict {
        let a = cbody
            .get("summary")
            .and_then(serde_json::Value::as_str)
            .unwrap_or("");
        let b = cbody
            .get("content")
            .and_then(serde_json::Value::as_str)
            .unwrap_or("");
        let c = cbody
            .get("memory")
            .and_then(|m| m.get("content"))
            .and_then(serde_json::Value::as_str)
            .unwrap_or("");
        // Python `A or B or C` — pick the first non-empty.
        if !a.is_empty() {
            a.to_string()
        } else if !b.is_empty() {
            b.to_string()
        } else {
            c.to_string()
        }
    } else {
        String::new()
    };
    assert!(
        s51_summary.len() >= 20,
        "S51 reader sees summary_len={} on the daemon wire shape; \
         expected >= 20 chars",
        s51_summary.len(),
    );
}

// ------------------------------------------------------------------
// v0.7.0 L9 — `GET /api/v1/tools/list` wired
// ------------------------------------------------------------------
//
// The endpoint must return 200 with a `tools[]` array of objects
// that each carry a `name`. Pure config enumeration — works on
// both backends without DB access.

#[tokio::test]
async fn http_tools_list_returns_200_with_tools_array_l9() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/tools/list", axum_get(tools_list))
        .with_state(test_app_state(state));
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/tools/list")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let tools = v["tools"].as_array().expect("tools array");
    assert!(
        !tools.is_empty(),
        "tools/list must enumerate at least one tool"
    );
    let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
    assert!(
        names.iter().any(|n| *n == "memory_capabilities"),
        "always-on `memory_capabilities` must appear in tools/list"
    );
}

// ------------------------------------------------------------------
// v0.7.0 L10 — `POST /api/v1/memory_load_family`
// ------------------------------------------------------------------
//
// Returns a 200 with `{family, memories:[...]}` on a valid family
// even on a freshly-empty DB (zero memories tagged — `count: 0`).
// Rejects unknown families with 400.

#[tokio::test]
async fn http_load_family_returns_200_on_known_family_l10() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memory_load_family", axum_post(load_family_handler))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"family": "core", "k": 5});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memory_load_family")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["family"], "core");
    assert!(v["memories"].is_array(), "memories must be an array");
    assert_eq!(v["k"], 5);
}

#[tokio::test]
async fn http_load_family_rejects_unknown_family_l10() {
    let state = test_state();
    let app = Router::new()
        .route("/api/v1/memory_load_family", axum_post(load_family_handler))
        .with_state(test_app_state(state));
    let body = serde_json::json!({"family": "totally-bogus"});
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/memory_load_family")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// ---------------- v0.7.0 H5 — verify_link replay protection ----------

/// H5 (v0.7.0 round-2): the verify body must accept the new
/// `verification_nonce` field while preserving back-compat with
/// clients that don't send it. Tests the wire shape only — the
/// full handler dispatch needs SAL fixtures and is covered by
/// the integration suite.
#[test]
fn h5_verify_link_body_deserialises_verification_nonce() {
    let body: VerifyLinkBody = serde_json::from_value(serde_json::json!({
        "source_id": "src",
        "target_id": "tgt",
        "verification_nonce": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
    }))
    .unwrap();
    assert_eq!(
        body.verification_nonce.as_deref(),
        Some("f47ac10b-58cc-4372-a567-0e02b2c3d479"),
        "H5 wire shape: nonce must round-trip from JSON to struct"
    );

    // Back-compat: bodies that omit the field must still parse,
    // with verification_nonce == None.
    let body: VerifyLinkBody = serde_json::from_value(serde_json::json!({
        "source_id": "src",
        "target_id": "tgt"
    }))
    .unwrap();
    assert!(
        body.verification_nonce.is_none(),
        "H5 back-compat: missing nonce must deserialise to None"
    );
}

/// H5: strict mode + missing nonce → 400 Bad Request. Drives the
/// handler through a real Router so the axum extractor chain +
/// the strict-mode short-circuit are both exercised.
#[tokio::test]
async fn h5_verify_link_strict_mode_rejects_missing_nonce_with_400() {
    let state = test_state();
    let mut app_state = test_app_state(state);
    app_state.verify_require_nonce = true;
    let app = Router::new()
        .route("/api/v1/links/verify", axum_post(verify_link_handler))
        .with_state(app_state);
    let body = serde_json::json!({
        "source_id": "src-id",
        "target_id": "tgt-id"
        // verification_nonce omitted on purpose
    });
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/links/verify")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .body(Body::from(serde_json::to_vec(&body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "strict-mode missing nonce must produce 400"
    );
    let bytes = http_body_util::BodyExt::collect(resp.into_body())
        .await
        .unwrap()
        .to_bytes();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let err = v["error"].as_str().unwrap_or("");
    assert!(
        err.contains("verification_nonce is required"),
        "H5 strict-mode 400 must name the missing field; got: {err}"
    );
}

/// H5: same-nonce replay must produce 409 Conflict on the second
/// request when the verify itself would otherwise succeed. We
/// exercise this via the in-process replay cache rather than the
/// full handler so the test doesn't need a populated link row.
#[test]
fn h5_replay_cache_dedups_identical_tuple() {
    use crate::identity::replay::{ReplayCache, ReplayDecision};
    let cache = ReplayCache::new();
    let link_id = "src|tgt|related_to";
    let nonce = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
    assert_eq!(
        cache.record_and_check(link_id, b"", nonce),
        ReplayDecision::Fresh,
        "first verify must be fresh"
    );
    assert_eq!(
        cache.record_and_check(link_id, b"", nonce),
        ReplayDecision::Replay,
        "repeat verify with same nonce must trigger replay"
    );
}

// ---------------------------------------------------------------------------
// #869 (2026-05-18) — `to_value_or_500` regression tests. Pins the
// behaviour of the helper that replaced the pre-fix
// `serde_json::to_value(&payload).unwrap_or_default()` pattern across
// the create_memory call sites.
// ---------------------------------------------------------------------------

/// Happy path: serialisable struct → `Ok(serde_json::Value)` carrying
/// the expected JSON object. Pins that the helper is a thin wrapper
/// for the success case (no extra wrapping / mutation).
#[test]
fn to_value_or_500_serialises_typed_struct() {
    let mem = Memory {
        id: "m1".into(),
        tier: Tier::Long,
        namespace: "ns".into(),
        title: "t".into(),
        content: "c".into(),
        tags: Vec::new(),
        priority: 5,
        confidence: 1.0,
        source: "test".into(),
        access_count: 0,
        created_at: Utc::now().to_rfc3339(),
        updated_at: Utc::now().to_rfc3339(),
        last_accessed_at: None,
        expires_at: None,
        metadata: serde_json::Map::new().into(),
        reflection_depth: 0,
        memory_kind: crate::models::MemoryKind::Observation,
        entity_id: None,
        persona_version: None,
        citations: Vec::new(),
        source_uri: None,
        source_span: None,
        confidence_source: crate::models::ConfidenceSource::CallerProvided,
        confidence_signals: None,
        confidence_decayed_at: None,
        version: 1,
        lifecycle_state: crate::models::LifecycleState::Open,
    };
    let value = super::to_value_or_500("test.happy", &mem).expect("Memory must serialise to JSON");
    assert_eq!(value["id"], "m1");
    assert_eq!(value["title"], "t");
}

/// Bad-encode path: a synthetic `Serialize` impl that always errors
/// drives the `Err(response)` branch. We use a custom type rather than
/// a stdlib oddity (`HashMap<f32, _>` etc.) so the test pins the
/// helper's behaviour without depending on stdlib-specific failure
/// modes that can shift between serde_json versions.
///
/// Pre-#869 the call site held `unwrap_or_default()` here and would
/// return `201 Created {}` to the caller — the load-bearing assertion
/// here is `expect_err`, which would have failed under the old code
/// because `serde_json::Value::default()` is `Value::Null`.
#[tokio::test]
async fn to_value_or_500_returns_500_on_encode_failure() {
    struct AlwaysErrors;
    impl serde::Serialize for AlwaysErrors {
        fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
            Err(serde::ser::Error::custom(
                "synthetic encode failure for #869 regression",
            ))
        }
    }
    let err_resp = super::to_value_or_500("test.bad", &AlwaysErrors)
        .expect_err("synthetic Serialize impl must fail serde_json encode");
    assert_eq!(err_resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
    let bytes = axum::body::to_bytes(err_resp.into_body(), 1024 * 1024)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let err = v["error"].as_str().unwrap_or_default();
    assert!(
        err.contains("internal server error"),
        "500 body must carry the sanitised error envelope, got: {err}"
    );
}

// ---------------------------------------------------------------------------
// #869 (2026-05-18) — `quorum_not_met_response` regression tests. Pins
// the helper that collapsed the ~30 inline 503 + `Retry-After: 2` +
// `Json(serde_json::to_value(&payload).unwrap_or_default())` sites
// across the per-domain handler modules into a single typed call.
// ---------------------------------------------------------------------------

/// Pins the wire shape: status, header, body. Wire compatibility with
/// the pre-#869 inline pattern is the load-bearing invariant — peers
/// that switch on `error == "quorum_not_met"` must keep working.
#[tokio::test]
async fn quorum_not_met_response_wire_compat() {
    use crate::federation::QuorumNotMetPayload;
    use crate::replication::{QuorumError, QuorumFailureReason};

    let err = QuorumError::QuorumNotMet {
        got: 1,
        needed: 2,
        reason: QuorumFailureReason::Unreachable,
    };
    let payload = QuorumNotMetPayload::from_err(&err);
    let resp = super::quorum_not_met_response(&payload);

    assert_eq!(
        resp.status(),
        StatusCode::SERVICE_UNAVAILABLE,
        "503 status preserved"
    );
    assert_eq!(
        resp.headers()
            .get("Retry-After")
            .and_then(|v| v.to_str().ok()),
        Some("2"),
        "Retry-After header preserved"
    );
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["error"], "quorum_not_met");
    assert_eq!(v["got"], 1);
    assert_eq!(v["needed"], 2);
    assert_eq!(v["reason"], "unreachable");
}

/// Pins the timeout-reason branch — the `InFlight` enum variant maps
/// to `"timeout"` per the `from_err` docstring so the wire never leaks
/// a fourth internal state to operators.
#[tokio::test]
async fn quorum_not_met_response_timeout_branch() {
    use crate::federation::QuorumNotMetPayload;
    use crate::replication::{QuorumError, QuorumFailureReason};

    let err = QuorumError::QuorumNotMet {
        got: 0,
        needed: 3,
        reason: QuorumFailureReason::Timeout,
    };
    let payload = QuorumNotMetPayload::from_err(&err);
    let resp = super::quorum_not_met_response(&payload);
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let bytes = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(v["reason"], "timeout");
    assert_eq!(v["got"], 0);
    assert_eq!(v["needed"], 3);
    // Pre-#869 a silent encode failure would have surfaced
    // `Value::Null` as the body — the 503 envelope would have looked
    // indistinguishable from a malformed response. This assertion
    // mechanically pins that the body is a real object.
    assert!(
        v.is_object(),
        "response body must be a typed object, got {v}"
    );
}

// --- #1416 — L4 `POST /api/v1/capture_turn` HTTP route tests ---
//
// The new route mirrors the MCP `memory_capture_turn` tool so
// postgres-backed daemons gain a callable L4 turn-capture surface
// (the MCP tool only ever runs against a local sqlite connection).
// These tests exercise the route end to end via `oneshot`, asserting
// the wire envelope + the RFC-0001 idempotency contract. They are
// backend-agnostic: both calls in the idempotency test share one
// `AppState` (and therefore one backing store under `--features sal`
// OR one `app.db` connection under the default build), so the
// dedup-hit assertion holds on either path.

fn capture_turn_test_router(state: Db) -> Router {
    Router::new()
        .route("/api/v1/capture_turn", axum_post(capture_turn))
        .with_state(test_app_state(state))
}

async fn post_capture_turn(
    app: &Router,
    agent_id: &str,
    body: &serde_json::Value,
) -> (StatusCode, serde_json::Value) {
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/capture_turn")
                .method("POST")
                .header(crate::HEADER_CONTENT_TYPE, crate::MIME_JSON)
                .header("x-agent-id", agent_id)
                .body(Body::from(serde_json::to_vec(body).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    (status, payload)
}

#[tokio::test]
async fn http_capture_turn_creates_then_dedups_1416() {
    let state = test_state();
    let app = capture_turn_test_router(state);
    let body = json!({
        "host_session_id": "http-sess-1416",
        "host_turn_index": 0,
        "role": "user",
        "content": "operator directive captured via HTTP L4"
    });

    // First call writes the row → 201 CREATED, dedup_hit:false,
    // attest_level:self_signed (no host signature supplied).
    let (status, payload) = post_capture_turn(&app, "alice", &body).await;
    assert_eq!(
        status,
        StatusCode::CREATED,
        "fresh capture must be 201, got {payload}"
    );
    assert_eq!(payload["dedup_hit"].as_bool(), Some(false));
    assert_eq!(payload["layer"].as_str(), Some("L4"));
    assert_eq!(payload["attest_level"].as_str(), Some("self_signed"));
    let first_id = payload["memory_id"]
        .as_str()
        .expect("memory_id present")
        .to_string();
    assert!(!first_id.is_empty(), "memory_id must be non-empty");

    // Second identical call is an idempotent replay → 200 OK,
    // dedup_hit:true, same memory_id, and NO attest_level (no fresh
    // write happened) per the shared envelope contract.
    let (status2, payload2) = post_capture_turn(&app, "alice", &body).await;
    assert_eq!(
        status2,
        StatusCode::OK,
        "idempotent replay must be 200, got {payload2}"
    );
    assert_eq!(payload2["dedup_hit"].as_bool(), Some(true));
    assert_eq!(payload2["memory_id"].as_str(), Some(first_id.as_str()));
    assert!(
        payload2.get("attest_level").is_none(),
        "replay envelope omits attest_level, got {payload2}"
    );
}

#[tokio::test]
async fn http_capture_turn_rejects_missing_required_field_1416() {
    // `content` is a required, no-`#[serde(default)]` field. A body
    // that omits it must be refused by the `JsonOrBadRequest`
    // extractor with 400 rather than storing an empty turn.
    let state = test_state();
    let app = capture_turn_test_router(state);
    let (status, _payload) = post_capture_turn(
        &app,
        "alice",
        &json!({ "host_session_id": "x", "host_turn_index": 0, "role": "user" }),
    )
    .await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn http_capture_turn_rejects_agent_id_mismatch_1413() {
    // #1413 — a body `metadata.agent_id` that disagrees with the
    // authenticated `X-Agent-Id` header must be refused (400) so a
    // caller cannot forge memories attributed to another identity.
    let state = test_state();
    let app = capture_turn_test_router(state);
    let (status, _payload) = post_capture_turn(
        &app,
        "alice",
        &json!({
            "host_session_id": "sess-mismatch",
            "host_turn_index": 0,
            "role": "user",
            "content": "forged",
            "metadata": { "agent_id": "mallory" }
        }),
    )
    .await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
}

// ---------------------------------------------------------------------------
// #1579 B4 — gzip response compression + TOON format negotiation
// ---------------------------------------------------------------------------

/// Seed `n` memories whose content matches the `b4 corpus` recall /
/// search queries below.
async fn seed_b4_corpus(state: &Db, n: usize) {
    let lock = state.lock().await;
    let now = Utc::now();
    for i in 0..n {
        let mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: "b4-test".into(),
            title: format!("b4 corpus row {i}"),
            content: format!(
                "gzip toon corpus row {i} with enough repeated filler text to make \
                 compression worthwhile — filler filler filler filler filler filler"
            ),
            tags: vec!["b4".into()],
            priority: 5,
            confidence: 1.0,
            source: "test".into(),
            access_count: 0,
            created_at: now.to_rfc3339(),
            updated_at: now.to_rfc3339(),
            last_accessed_at: None,
            expires_at: None,
            // #910 — collective scope so the anonymous HTTP caller in
            // these fixtures passes the visibility filter (same
            // convention as `insert_test_memory`).
            metadata: serde_json::json!({"scope": "collective", "agent_id": "b4-tester"}),
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        db::insert(&lock.0, &mem).unwrap();
    }
}

/// Full production router (every middleware layer, incl. the #1579 B4
/// `CompressionLayer`) over the shared test fixtures.
fn full_router(state: Db) -> axum::Router {
    crate::build_router(
        ApiKeyState {
            key: None,
            mtls_enforced: false,
        },
        test_app_state(state),
    )
}

/// #1579 B4 — a recall response is gzip-coded when (and only when)
/// the caller advertises `Accept-Encoding: gzip`, and the compressed
/// body is materially smaller than the identity body.
#[tokio::test]
async fn issue_1579_b4_gzip_round_trip_honors_accept_encoding() {
    let state = test_state();
    seed_b4_corpus(&state, 20).await;
    let app = full_router(state);

    // Identity request — no Accept-Encoding → no content-encoding.
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=20")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert!(
        resp.headers()
            .get(axum::http::header::CONTENT_ENCODING)
            .is_none(),
        "no Accept-Encoding → identity response"
    );
    let identity_body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&identity_body).unwrap();
    assert!(v["count"].as_u64().unwrap() > 0, "fixture must recall rows");

    // Gzip request — Accept-Encoding: gzip → content-encoding: gzip +
    // gzip magic bytes + a materially smaller body.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=20")
                .method("GET")
                .header(axum::http::header::ACCEPT_ENCODING, "gzip")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers()
            .get(axum::http::header::CONTENT_ENCODING)
            .and_then(|v| v.to_str().ok()),
        Some("gzip"),
        "Accept-Encoding: gzip must produce a gzip-coded response"
    );
    let gzip_body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    assert!(
        gzip_body.len() >= 2 && gzip_body[0] == 0x1f && gzip_body[1] == 0x8b,
        "gzip body must start with the gzip magic bytes"
    );
    assert!(
        gzip_body.len() < identity_body.len(),
        "gzip body ({}) must be smaller than identity body ({})",
        gzip_body.len(),
        identity_body.len()
    );
    eprintln!(
        "issue_1579_b4 measured recall response: identity={}B gzip={}B ({:.1}x)",
        identity_body.len(),
        gzip_body.len(),
        identity_body.len() as f64 / gzip_body.len() as f64
    );
}

/// #1579 B4 — the SSE `/api/v1/approvals/stream` surface must NOT be
/// gzip-coded even when the caller advertises gzip: the
/// CompressionLayer's default `NotForContentType` predicate exempts
/// `text/event-stream` so events flush immediately instead of
/// buffering inside a gzip stream.
#[tokio::test]
async fn issue_1579_b4_sse_stream_is_not_gzip_compressed() {
    let state = test_state();
    let app = full_router(state);
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/approvals/stream")
                .method("GET")
                .header(axum::http::header::ACCEPT_ENCODING, "gzip")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(
        resp.headers()
            .get(axum::http::header::CONTENT_TYPE)
            .and_then(|v| v.to_str().ok())
            .map(|s| s.split(';').next().unwrap_or(s).trim().to_string()),
        Some("text/event-stream".to_string()),
        "fixture must actually hit the SSE surface"
    );
    assert!(
        resp.headers()
            .get(axum::http::header::CONTENT_ENCODING)
            .is_none(),
        "SSE stream must never be gzip-coded"
    );
}

/// #1579 B4 — `format=toon_compact` on HTTP recall returns the TOON
/// envelope as `text/plain`, materially smaller than the JSON default.
#[tokio::test]
async fn issue_1579_b4_recall_format_toon_compact() {
    let state = test_state();
    seed_b4_corpus(&state, 10).await;
    let app = Router::new()
        .route("/api/v1/recall", axum_get(recall_memories_get))
        .with_state(test_app_state(state));

    let json_resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=10")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(json_resp.status(), StatusCode::OK);
    let json_body = axum::body::to_bytes(json_resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&limit=10&format=toon_compact")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert!(
        resp.headers()
            .get(axum::http::header::CONTENT_TYPE)
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .starts_with("text/plain"),
        "TOON responses are text/plain"
    );
    let toon_body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let toon = String::from_utf8(toon_body.to_vec()).unwrap();
    assert!(
        toon.contains("memories["),
        "TOON header line expected: {toon}"
    );
    assert!(toon.contains("b4 corpus row"), "TOON rows expected: {toon}");
    assert!(
        toon_body.len() < json_body.len(),
        "toon_compact ({}) must be smaller than the JSON envelope ({})",
        toon_body.len(),
        json_body.len()
    );
    eprintln!(
        "issue_1579_b4 measured recall response: json={}B toon_compact={}B ({:.0}% smaller)",
        json_body.len(),
        toon_body.len(),
        (1.0 - toon_body.len() as f64 / json_body.len() as f64) * 100.0
    );
}

/// #1579 B4 — `format=toon` (non-compact) works on recall, and an
/// invalid `format` value is a 400 carrying the SSOT message.
#[tokio::test]
async fn issue_1579_b4_recall_format_toon_and_invalid() {
    let state = test_state();
    seed_b4_corpus(&state, 3).await;
    let app = Router::new()
        .route("/api/v1/recall", axum_get(recall_memories_get))
        .with_state(test_app_state(state));

    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&format=toon")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let toon = String::from_utf8(body.to_vec()).unwrap();
    // Non-compact column set includes created_at.
    assert!(
        toon.contains("created_at"),
        "non-compact TOON expected: {toon}"
    );

    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/recall?context=gzip%20toon%20corpus&namespace=b4-test&format=yaml")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(
        v["error"].as_str().unwrap(),
        crate::toon::invalid_format_msg("yaml"),
        "400 must carry the SSOT invalid-format message"
    );
}

/// #1579 B4 — `format` negotiation on HTTP search: toon_compact +
/// invalid value, plus json-by-default.
#[tokio::test]
async fn issue_1579_b4_search_format_negotiation() {
    let state = test_state();
    seed_b4_corpus(&state, 5).await;
    let app = Router::new()
        .route("/api/v1/search", axum_get(search_memories))
        .with_state(test_app_state(state));

    // Default stays JSON.
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/search?q=gzip&namespace=b4-test")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(v["count"].as_u64().unwrap() > 0, "fixture must match rows");

    // toon_compact returns the TOON text envelope.
    let resp = app
        .clone()
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/search?q=gzip&namespace=b4-test&format=toon_compact")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let toon = String::from_utf8(body.to_vec()).unwrap();
    assert!(
        toon.contains("memories["),
        "search TOON normalizes results: {toon}"
    );
    assert!(toon.contains("b4 corpus row"));

    // Invalid format → 400 + SSOT message.
    let resp = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/api/v1/search?q=gzip&namespace=b4-test&format=xml")
                .method("GET")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let body = axum::body::to_bytes(resp.into_body(), crate::TEST_BODY_READ_CAP)
        .await
        .unwrap();
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(
        v["error"].as_str().unwrap(),
        crate::toon::invalid_format_msg("xml")
    );
}
