Skip to main content

trusty_memory/
web.rs

1//! HTTP API + embedded SPA shell for the trusty-memory admin UI.
2//!
3//! Why: The web admin panel is the primary GUI for non-MCP clients. Bundling
4//! the Svelte build via `rust-embed` keeps deployment to "drop the binary on
5//! a host"; the JSON API surface mirrors the MCP tool set so anything
6//! trusty-memory can do via Claude Code can also be done via curl or browser.
7//! What: All `/api/v1/*` handlers (status, palaces, drawers, recall, KG,
8//! config, chat) plus an embedded-asset fallback that serves `ui/dist/`.
9//! Test: `cargo test -p trusty-memory-mcp web::tests` covers the asset
10//! fallback and JSON shape of every read endpoint against an in-memory
11//! palace built on a `tempdir`.
12
13use crate::attribution::{
14    CreatorInfo, CreatorSource, HTTP_DEFAULT_CLIENT, X_TRUSTY_CLIENT_CWD, X_TRUSTY_CLIENT_NAME,
15};
16use crate::hook_emit::HookEventPayload;
17use crate::{ActivityFilter, ActivitySource, AppState, DaemonEvent};
18use axum::{
19    body::Body,
20    extract::{Path as AxumPath, Query, State},
21    http::{header, HeaderMap, HeaderValue, Request, StatusCode},
22    response::{IntoResponse, Response},
23    routing::{delete, get, post},
24    Json, Router,
25};
26use rust_embed::RustEmbed;
27use serde::{Deserialize, Serialize};
28use serde_json::{json, Value};
29use trusty_common::memory_core::community::KnowledgeGap;
30use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
31use trusty_common::memory_core::retrieval::recall_with_default_embedder;
32use trusty_common::memory_core::store::kg::Triple;
33use uuid::Uuid;
34
35/// Dedicated palace id used by the `/health` round-trip probe (issue #185).
36///
37/// Why: Earlier revisions of `run_health_round_trip` picked whichever palace
38/// happened to be first on disk (APFS creation order on macOS), which meant
39/// the probe always wrote — and, if recall failed, *leaked* — a drawer in a
40/// real user-facing palace. Routing the probe to a dedicated palace whose id
41/// starts with the reserved `__` prefix means leaked drawers are confined to a
42/// palace the user never sees (filtered by `MemoryService::list_palaces`) and
43/// real palaces stay clean.
44/// What: A constant `&str` reused by the probe and tests. The leading double
45/// underscore is the project-wide convention for "system" palaces hidden from
46/// user listings.
47/// Test: `health_probe_palace_is_invisible`, `health_probe_cleans_up_on_success`,
48/// `health_probe_cleans_up_on_recall_miss`.
49pub(crate) const HEALTH_PROBE_PALACE: &str = "__health_probe__";
50
51/// Embedded UI assets produced by `pnpm build` in `ui/`.
52///
53/// Why: Single-binary deploys with no separate static-file dance. `build.rs`
54/// runs the Vite build before compilation so this folder is always populated.
55/// What: All files under `ui/dist/` are included in the binary.
56/// Test: `serves_index_html` confirms the SPA shell loads.
57#[derive(RustEmbed)]
58// Monorepo migration: upstream trusty-memory put the Svelte UI at the repo
59// root (`ui/dist/`), so the original path was `$CARGO_MANIFEST_DIR/../../ui/dist/`.
60// In the trusty-tools monorepo we keep the UI inside the crate to avoid
61// polluting the workspace root with per-crate asset directories.
62#[folder = "$CARGO_MANIFEST_DIR/ui/dist/"]
63struct WebAssets;
64
65/// Build the public router with API routes + SPA asset fallback.
66///
67/// Why: `run_http` calls this so the same router shape is used in tests.
68/// What: All API routes under `/api/v1`, fallback to the SPA shell.
69/// Test: `serves_index_html` and `status_endpoint_returns_payload`.
70pub fn router() -> Router<AppState> {
71    // axum 0.8 path syntax uses `{param}` instead of `:param`. The shared
72    // `trusty_common::server::with_standard_middleware` layer brings in CORS,
73    // tracing, and gzip (with SSE excluded) so we don't drift from sibling
74    // trusty-* daemons.
75    let router = Router::new()
76        .route("/api/v1/status", get(status))
77        .route("/api/v1/config", get(config))
78        .route("/api/v1/palaces", get(list_palaces).post(create_palace))
79        .route(
80            "/api/v1/palaces/{id}",
81            get(get_palace_handler)
82                .delete(delete_palace_handler)
83                .patch(update_palace_handler),
84        )
85        .route(
86            "/api/v1/palaces/{id}/drawers",
87            get(list_drawers).post(create_drawer),
88        )
89        .route(
90            "/api/v1/palaces/{id}/drawers/{drawer_id}",
91            delete(delete_drawer),
92        )
93        // Issue #70 — `/memories` is a backward-compatible alias for `/drawers`.
94        // Some clients (and earlier docs) POST/GET against `…/memories`, which
95        // 404'd because only `/drawers` was registered. Aliasing here keeps
96        // both vocabularies working against the same handlers without breaking
97        // existing `/drawers` callers.
98        .route(
99            "/api/v1/palaces/{id}/memories",
100            get(list_drawers).post(create_drawer),
101        )
102        .route(
103            "/api/v1/palaces/{id}/memories/{drawer_id}",
104            delete(delete_drawer),
105        )
106        .route("/api/v1/palaces/{id}/recall", get(recall_handler))
107        .route("/api/v1/recall", get(recall_all_handler))
108        .route("/api/v1/palaces/{id}/kg", get(kg_query).post(kg_assert))
109        .route("/api/v1/palaces/{id}/kg/subjects", get(kg_list_subjects))
110        .route(
111            "/api/v1/palaces/{id}/kg/subjects_with_counts",
112            get(kg_list_subjects_with_counts),
113        )
114        .route("/api/v1/palaces/{id}/kg/all", get(kg_list_all))
115        .route("/api/v1/palaces/{id}/kg/graph", get(kg_graph))
116        .route("/api/v1/palaces/{id}/kg/count", get(kg_count))
117        .route(
118            "/api/v1/palaces/{id}/kg/triples/{triple_id}",
119            delete(kg_delete_triple),
120        )
121        .route(
122            "/api/v1/palaces/{id}/dream/status",
123            get(palace_dream_status),
124        )
125        .route("/api/v1/dream/status", get(dream_status))
126        .route("/api/v1/dream/run", post(dream_run))
127        .route("/api/v1/kg/gaps", get(kg_gaps_handler))
128        .route("/api/v1/kg/prompt-context", get(prompt_context_handler))
129        .route("/api/v1/kg/aliases", post(add_alias_handler))
130        .route(
131            "/api/v1/kg/prompt-facts",
132            get(list_prompt_facts_handler).delete(remove_prompt_fact_handler),
133        )
134        .route("/api/v1/chat", post(crate::chat::chat_handler))
135        .route("/api/v1/chat/providers", get(crate::chat::list_providers))
136        .route(
137            "/api/v1/palaces/{id}/chat/sessions",
138            get(crate::chat::list_chat_sessions).post(crate::chat::create_chat_session),
139        )
140        .route(
141            "/api/v1/palaces/{id}/chat/sessions/{session_id}",
142            get(crate::chat::get_chat_session).delete(crate::chat::delete_chat_session),
143        )
144        // Issue #99: inter-project messaging.
145        .route(
146            "/api/v1/messages",
147            get(crate::chat::list_messages_handler).post(crate::chat::send_message_handler),
148        )
149        .route(
150            "/api/v1/messages/mark_read",
151            post(crate::chat::mark_message_read_handler),
152        )
153        .route("/health", get(health))
154        .route("/api/v1/logs/tail", get(logs_tail))
155        .route("/api/v1/activity", get(activity_handler))
156        .route("/api/v1/activity/hook", post(hook_activity_handler))
157        .route("/api/v1/admin/stop", post(admin_stop))
158        // Issue: fire-and-forget memory save for callers that cannot speak
159        // MCP. Sub-agents spawned via Claude Code's Agent tool inherit no
160        // MCP connections, so `memory_remember` is unreachable to them.
161        // This endpoint lets the agent shell out to `trusty-memory note`
162        // (which in turn POSTs here) and the request returns 202 the moment
163        // the body is parsed — the actual `memory_remember` dispatch runs
164        // on a detached `tokio::spawn`. Failures are logged at warn but
165        // never surface to the caller because the contract is one-way.
166        .route("/api/v1/remember", post(remember_async))
167        // Multi-transport refactor: a single JSON-RPC 2.0 endpoint that
168        // accepts the same envelopes the UDS transport speaks. Lets
169        // browser clients, curl, and the stdio bridge fallback hit the
170        // tool surface without learning the REST routes. The REST
171        // routes above remain for backwards compatibility.
172        .route("/rpc", post(rpc_handler))
173        .fallback(static_handler);
174
175    trusty_common::server::with_standard_middleware(router)
176}
177
178// ---------------------------------------------------------------------------
179// Health check
180// ---------------------------------------------------------------------------
181
182/// Liveness/version payload for `GET /health`.
183///
184/// Why: `daemon_probe` requires an HTTP 200 from `/health` to confirm that the
185/// port is owned by this daemon (and not a stale or foreign process). Issue
186/// #35 enriches it with process resource metrics so operators (and the admin
187/// UI) can see RSS, disk footprint, CPU, and uptime in one cheap call.
188/// The fd-exhaustion fix adds `open_fds` and `fd_soft_limit` so operators can
189/// see "244 / 256" before EMFILE hits.
190/// What: Carries a fixed `status` string, the compile-time crate version,
191/// the issue-#35 resource block, and `open_fds` / `fd_soft_limit`.
192/// Test: Asserted by `health_endpoint_returns_ok`,
193/// `health_endpoint_includes_resource_fields`, and
194/// `health_endpoint_includes_fd_gauge` in this module's tests.
195#[derive(serde::Serialize)]
196struct HealthResponse {
197    /// `"ok"` when the round-trip smoke test succeeds (or no palace exists
198    /// yet), `"degraded"` when store/recall is broken (issue #71). Owned
199    /// `String` so the handler can report different statuses without
200    /// requiring static lifetimes.
201    status: String,
202    /// Populated only when `status == "degraded"` (issue #71). Carries a
203    /// short phrase identifying which round-trip stage failed so operators
204    /// can triage quickly (e.g. `"store failed: ..."`).
205    #[serde(skip_serializing_if = "Option::is_none")]
206    detail: Option<String>,
207    version: &'static str,
208    /// Current process Resident Set Size in megabytes (issue #35). Sampled
209    /// via the shared `SysMetrics` on each health request.
210    rss_mb: u64,
211    /// On-disk footprint of the daemon's `data_root` in bytes (issue #35):
212    /// the sum of every palace file. Refreshed by a background task every
213    /// 10 s; `0` until the first walk completes.
214    disk_bytes: u64,
215    /// Current process CPU usage as a percentage (issue #35), where `100.0`
216    /// means one fully-saturated core. The first reading after daemon start
217    /// may be `0.0` until a delta window exists.
218    cpu_pct: f32,
219    /// Seconds elapsed since the daemon started (issue #35).
220    uptime_secs: u64,
221    /// Bound `host:port` of the HTTP listener. Why: dynamic port selection
222    /// (7070..=7079 + OS fallback) means clients cannot assume `7070`; this
223    /// field advertises the real port without forcing them to read
224    /// `~/.trusty-memory/http_addr`. `None` when the daemon was constructed
225    /// without ever binding (tests that drive the router with `TestServer`).
226    #[serde(skip_serializing_if = "Option::is_none")]
227    addr: Option<String>,
228    /// Number of file descriptors currently open by this process (fd-exhaustion
229    /// gauge). `None` when the platform does not expose this cheaply (rare).
230    /// Sampled on every `/health` call via [`crate::fd_metrics::count_open_fds`].
231    #[serde(skip_serializing_if = "Option::is_none")]
232    open_fds: Option<u64>,
233    /// Soft `RLIMIT_NOFILE` ceiling for this process (fd-exhaustion gauge).
234    /// `None` when `getrlimit` fails or returns `RLIM_INFINITY` (unlimited).
235    /// Together with `open_fds`, lets operators see "244 / 256" before EMFILE.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    fd_soft_limit: Option<u64>,
238    /// Newer crates.io version available, if any (issue #537).
239    ///
240    /// Why: surfaces update availability without polling crates.io on every
241    /// health call — a single background check at startup stores the result
242    /// here for the health handler to read cheaply.
243    /// What: `null`/absent = up to date or check not completed; `"x.y.z"` =
244    /// the available newer version.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    update_available: Option<String>,
247    /// Daemon readiness state (issues #910 / #911).
248    ///
249    /// Why: operators and monitoring scripts need to distinguish "the daemon
250    /// is alive but the embedder hasn't finished compiling yet" from "the
251    /// daemon is fully operational". Before this field, a fresh daemon looked
252    /// healthy to external monitors even while `memory_remember` /
253    /// `memory_recall` calls were returning warming errors.
254    /// What: `"warming"` until the embedder init succeeds; `"ready"` once
255    /// `spawn_startup_tasks` flips `AppState::daemon_readiness`.
256    daemon_state: String,
257}
258
259/// `GET /health` — unauthenticated liveness probe with store/recall smoke test.
260///
261/// Why: Gives `daemon_probe` and external monitors a cheap way to confirm port
262/// ownership without touching palace state. Issue #35 additionally reports
263/// process RSS, CPU, the `data_root` disk footprint, and uptime. Issue #71
264/// upgrades the check to a full memory round-trip (store → recall → verify →
265/// delete) so operators learn about store/recall regressions immediately
266/// instead of after a real request fails. Issue #185 routes the round-trip
267/// to a dedicated `__health_probe__` palace (hidden from user listings) so
268/// the probe never leaks drawers into a real user palace even on recall
269/// failures. The fd-exhaustion fix adds `open_fds` and `fd_soft_limit` so
270/// operators can catch "approaching ceiling" before EMFILE hits.
271/// What: Returns HTTP 200 with `{status, version, rss_mb, disk_bytes,
272/// cpu_pct, uptime_secs, open_fds?, fd_soft_limit?, detail?}`. RSS + CPU are
273/// sampled live; `disk_bytes` is read from the background ticker;
274/// `uptime_secs` is elapsed since `state.started_at`; `open_fds` and
275/// `fd_soft_limit` are sampled best-effort (absent when the platform does not
276/// expose them cheaply). The handler provisions the dedicated probe palace if
277/// missing and then attempts a full remember/recall/forget cycle — `status`
278/// is `"ok"` on success, `"degraded"` with a `detail` string explaining the
279/// failing stage otherwise. The probe never returns non-200 so monitors
280/// keyed on HTTP status still see the daemon as up.
281/// Test: `health_endpoint_returns_ok`,
282/// `health_endpoint_includes_resource_fields`,
283/// `health_endpoint_includes_fd_gauge`,
284/// `health_endpoint_round_trip_on_fresh_install_is_ok`,
285/// `health_endpoint_round_trip_with_palace_is_ok`,
286/// `health_probe_palace_is_invisible`,
287/// `health_probe_cleans_up_on_success`,
288/// `health_probe_cleans_up_on_recall_miss`.
289async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
290    let (rss_mb, cpu_pct) = {
291        let mut metrics = state.sys_metrics.lock().await;
292        metrics.sample()
293    };
294    let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
295    let uptime_secs = state.started_at.elapsed().as_secs();
296    let addr = state.bound_addr.get().map(|a| a.to_string());
297
298    // fd-exhaustion gauge: sample best-effort; failures return None (not an
299    // error so we do not have to import the fd_metrics crate in every test
300    // that drives this handler via in-process TestServer).
301    let open_fds = crate::fd_metrics::count_open_fds();
302    let fd_soft_limit = crate::fd_metrics::fd_soft_limit();
303
304    let (status, detail) = match run_health_round_trip(&state).await {
305        Ok(()) => ("ok".to_string(), None),
306        Err(err) => {
307            tracing::warn!("/health round-trip degraded: {err}");
308            ("degraded".to_string(), Some(err.to_string()))
309        }
310    };
311
312    let update_available = state.update_available.lock().ok().and_then(|g| g.clone());
313    // Issues #910/#911: surface readiness so monitors and Claude Code can
314    // distinguish "alive but warming" from "fully ready".
315    let daemon_state = match state.readiness() {
316        crate::DaemonReadiness::Warming => "warming",
317        crate::DaemonReadiness::Ready => "ready",
318    }
319    .to_string();
320
321    Json(HealthResponse {
322        status,
323        detail,
324        version: env!("CARGO_PKG_VERSION"),
325        rss_mb,
326        disk_bytes,
327        cpu_pct,
328        uptime_secs,
329        addr,
330        open_fds,
331        fd_soft_limit,
332        update_available,
333        daemon_state,
334    })
335}
336
337/// Stages of the `/health` round-trip that can fail (issue #71).
338///
339/// Why: `thiserror`-derived enum gives every failure point a stable phrase the
340/// handler can render into the `detail` field without printing implementation
341/// detail or full backtraces. Issue #185 dropped the `NoPalaces` and
342/// `ListPalaces` sentinels: the probe now provisions its dedicated
343/// `__health_probe__` palace itself, so neither short-circuit can occur.
344/// What: One variant per stage (open palace, ensure-probe-palace, store,
345/// recall, missing-in-results, delete).
346/// Test: Exercised indirectly by the `health_endpoint_round_trip_*` and
347/// `health_probe_*` tests.
348#[derive(Debug, thiserror::Error)]
349enum HealthProbeError {
350    #[error("open palace failed: {0}")]
351    OpenPalace(String),
352    #[error("provision health probe palace failed: {0}")]
353    EnsureProbePalace(String),
354    #[error("store failed: {0}")]
355    Store(String),
356    #[error("recall failed: {0}")]
357    Recall(String),
358    #[error("recall did not return the probe drawer (id={0})")]
359    ProbeMissing(Uuid),
360    #[error("delete probe drawer failed: {0}")]
361    Delete(String),
362}
363
364/// Ensure the dedicated `__health_probe__` palace exists on disk.
365///
366/// Why: Issue #185 — picking whichever palace `list_palaces` returns first
367/// leaked health-probe drawers into a real user palace whenever recall failed
368/// or returned an empty result. Routing the probe to a dedicated palace whose
369/// id starts with the reserved `__` prefix confines any leak (e.g. a daemon
370/// crash mid-round-trip) to a palace the user can never see. This helper is
371/// idempotent: it is safe to call on every `/health` request, even when the
372/// palace already exists.
373/// What: Calls `PalaceRegistry::open_palace` first (cheap cache hit when the
374/// palace is already registered). If the palace metadata is missing on disk,
375/// creates it via `PalaceRegistry::create_palace` with a description that
376/// flags its purpose. Either path returns success when the palace is ready
377/// for the round-trip; failures propagate as `HealthProbeError::EnsureProbePalace`.
378/// Test: `health_probe_palace_is_invisible`, `health_probe_cleans_up_on_success`,
379/// `health_probe_cleans_up_on_recall_miss`.
380fn ensure_health_probe_palace(state: &AppState) -> Result<(), HealthProbeError> {
381    let id = PalaceId::new(HEALTH_PROBE_PALACE);
382
383    // Fast path: already registered in-memory, no disk hit needed.
384    if state.registry.get(&id).is_some() {
385        return Ok(());
386    }
387
388    // Try to open from disk first — succeeds on every request after the
389    // first one once the palace has been persisted.
390    if state.registry.open_palace(&state.data_root, &id).is_ok() {
391        return Ok(());
392    }
393
394    // Cold path: first run on this `data_root`. Create the palace metadata
395    // on disk so subsequent probes hit the open-path above.
396    let palace = Palace {
397        id: id.clone(),
398        name: HEALTH_PROBE_PALACE.to_string(),
399        description: Some(
400            "Internal health-probe palace (issue #185). Hidden from listings; \
401             holds short-lived round-trip drawers cleaned up on every probe."
402                .to_string(),
403        ),
404        created_at: chrono::Utc::now(),
405        data_dir: state.data_root.join(HEALTH_PROBE_PALACE),
406    };
407    state
408        .registry
409        .create_palace(&state.data_root, palace)
410        .map_err(|e| HealthProbeError::EnsureProbePalace(format!("{e:#}")))?;
411    Ok(())
412}
413
414/// Execute a remember/recall/forget cycle against the dedicated probe palace.
415///
416/// Why: `/health` used to return `status: "ok"` even when `POST /drawers` or
417/// the recall path was broken — only that the process was alive. Issue #71
418/// asks the probe to actually exercise the store and recall service layer
419/// (no HTTP loopback) so monitors detect data-plane regressions on the next
420/// poll instead of waiting for a real client to surface them. Issue #185
421/// additionally requires the probe to (a) never touch user-facing palaces and
422/// (b) never leak drawers even when recall fails or returns an empty result.
423/// What: Provisions the dedicated `__health_probe__` palace via
424/// [`ensure_health_probe_palace`], opens its handle, stores a content-unique
425/// probe drawer via `PalaceHandle::remember`, runs
426/// `recall_with_default_embedder` with the probe phrase, and then **always**
427/// attempts `PalaceHandle::forget` *before* propagating any recall error so a
428/// failing recall (Err *or* empty result) can never leave a drawer behind.
429/// The probe palace is hidden from `MemoryService::list_palaces`, so any rare
430/// leak (e.g. mid-call daemon crash) is confined to a palace the user can't see.
431/// Test: Indirect — `health_endpoint_round_trip_with_palace_is_ok`,
432/// `health_endpoint_round_trip_on_fresh_install_is_ok`, plus the three
433/// `health_probe_*` cleanup tests added for issue #185.
434async fn run_health_round_trip(state: &AppState) -> Result<(), HealthProbeError> {
435    // Issue #185: always use the dedicated probe palace. Provision it on the
436    // first request so a fresh install with zero user palaces still exercises
437    // the full data plane — no more `NoPalaces` short-circuit.
438    ensure_health_probe_palace(state)?;
439    let probe_id = PalaceId::new(HEALTH_PROBE_PALACE);
440    let handle = state
441        .registry
442        .open_palace(&state.data_root, &probe_id)
443        .map_err(|e| HealthProbeError::OpenPalace(format!("{e:#}")))?;
444
445    // Delegate the cleanup-ordering logic to the testable helper so unit tests
446    // can substitute the recall implementation. The real handler always uses
447    // the shared ONNX embedder.
448    run_health_round_trip_inner(handle, |handle, query| async move {
449        recall_with_default_embedder(&handle, &query, 5)
450            .await
451            .map_err(|e| HealthProbeError::Recall(format!("{e:#}")))
452    })
453    .await
454}
455
456/// Store-recall-forget core that always cleans up the probe drawer.
457///
458/// Why: Issue #185 — the cleanup invariant ("the probe drawer is always
459/// deleted before any error returns") is the central correctness property of
460/// the health round-trip. Splitting it out from `run_health_round_trip` lets
461/// the tests inject a recall stub that returns `Ok(empty)` or
462/// `Err(Recall(...))` and prove the invariant directly, without relying on
463/// the ONNX embedder.
464/// What: Stores a content-unique probe drawer via `PalaceHandle::remember`,
465/// invokes `recall` with the probe phrase, and then **always** calls
466/// `PalaceHandle::forget` *before* propagating any recall error. The recall
467/// result is evaluated after the forget so a missing or errored recall can
468/// never leave a drawer behind. Cleanup errors are reported only when recall
469/// succeeded; otherwise the upstream recall error is preserved as the root
470/// cause for operators.
471/// Test: `health_probe_cleans_up_on_recall_miss` and
472/// `health_probe_cleans_up_on_recall_error` exercise both failure modes with
473/// a stubbed recall; `health_probe_cleans_up_on_success` covers the happy path.
474async fn run_health_round_trip_inner<F, Fut>(
475    handle: std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
476    recall: F,
477) -> Result<(), HealthProbeError>
478where
479    F: FnOnce(std::sync::Arc<trusty_common::memory_core::PalaceHandle>, String) -> Fut,
480    Fut: std::future::Future<
481        Output = Result<Vec<trusty_common::memory_core::retrieval::RecallResult>, HealthProbeError>,
482    >,
483{
484    // Content-unique probe phrase. `__trusty_memory_healthcheck__` makes the
485    // probe identifiable in logs / drawer dumps if a forget step is ever
486    // skipped (e.g. handler panic between store and delete); the UUID
487    // guarantees uniqueness across concurrent probes.
488    let probe_token = Uuid::new_v4();
489    let probe_content = format!("__trusty_memory_healthcheck__ probe {probe_token}");
490
491    let drawer_id = handle
492        .remember(
493            probe_content.clone(),
494            RoomType::General,
495            vec!["healthcheck".to_string()],
496            0.0,
497        )
498        .await
499        .map_err(|e| HealthProbeError::Store(format!("{e:#}")))?;
500
501    let recall_result = recall(handle.clone(), probe_content).await;
502
503    // Issue #185: cleanup runs BEFORE we propagate any recall error so the
504    // probe can never leave a drawer behind. Both the Err and the
505    // empty-result failure modes used to bypass forget; this ordering closes
506    // both holes. Cleanup errors are surfaced only when the recall path
507    // itself succeeded; otherwise we preserve the upstream recall failure as
508    // the root cause for operators.
509    let delete_result = handle.forget(drawer_id).await;
510
511    match recall_result {
512        Ok(hits) => {
513            if !hits.iter().any(|hit| hit.drawer.id == drawer_id) {
514                return Err(HealthProbeError::ProbeMissing(drawer_id));
515            }
516        }
517        Err(e) => return Err(e),
518    }
519
520    delete_result.map_err(|e| HealthProbeError::Delete(format!("{e:#}")))?;
521    Ok(())
522}
523
524// ---------------------------------------------------------------------------
525// Logs tail + admin stop (issue #35)
526// ---------------------------------------------------------------------------
527
528/// Default number of log lines returned by `GET /api/v1/logs/tail` when `n`
529/// is absent. 100 lines is enough context for a glance without a huge payload.
530const DEFAULT_LOGS_TAIL_N: usize = 100;
531
532/// Hard ceiling on `GET /api/v1/logs/tail?n=` — equal to the ring-buffer
533/// capacity, so a request can never ask for more lines than the buffer holds.
534const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
535
536fn default_logs_tail_n() -> usize {
537    DEFAULT_LOGS_TAIL_N
538}
539
540/// Query parameters for `GET /api/v1/logs/tail`.
541///
542/// Why (issue #35): callers ask for a bounded number of recent log lines;
543/// `n` defaults to a useful page size and is clamped server-side so a
544/// misconfigured client cannot request more lines than the buffer holds.
545/// What: `n` is optional; absent → [`DEFAULT_LOGS_TAIL_N`]. Clamped to
546/// `[1, MAX_LOGS_TAIL_N]` in the handler.
547/// Test: `logs_tail_clamps_n` exercises the clamp.
548#[derive(serde::Deserialize)]
549struct LogsTailParams {
550    #[serde(default = "default_logs_tail_n")]
551    n: usize,
552}
553
554/// `GET /api/v1/logs/tail?n=200` — return the most recent N tracing log lines.
555///
556/// Why (issue #35): operators debugging a running daemon want recent logs
557/// over HTTP without SSHing to the box or restarting with a different
558/// `RUST_LOG`. The in-memory ring buffer (fed by the `LogBufferLayer` wired
559/// into the subscriber at startup) makes this near-free.
560/// What: clamps `n` to `[1, MAX_LOGS_TAIL_N]`, drains the tail of
561/// `state.log_buffer`, and returns `{ "lines": [...], "total": <buffered> }`
562/// where `total` is the number of lines currently buffered (so callers can
563/// tell whether the ring has wrapped).
564/// Test: `logs_tail_returns_recent_lines` and `logs_tail_clamps_n`.
565async fn logs_tail(
566    State(state): State<AppState>,
567    Query(params): Query<LogsTailParams>,
568) -> Json<Value> {
569    let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
570    let lines = state.log_buffer.tail(n);
571    Json(serde_json::json!({
572        "lines": lines,
573        "total": state.log_buffer.len(),
574    }))
575}
576
577/// `POST /api/v1/admin/stop` — request a graceful shutdown of the daemon.
578///
579/// Why (issue #35): the admin UI and operators want a one-call way to stop
580/// the daemon without resolving its PID and sending a signal. The daemon is
581/// localhost-only and trusts every caller, so no auth is required.
582/// What: spawns a detached task that sleeps 200 ms (giving this HTTP response
583/// time to flush to the client) and then calls `std::process::exit(0)`.
584/// Returns `{ "ok": true, "message": "shutting down" }` immediately.
585/// Test: `admin_stop_returns_ok` asserts the response shape (it does not
586/// drive the real exit — that would terminate the test process).
587async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
588    tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
589    tokio::spawn(async {
590        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
591        std::process::exit(0);
592    });
593    Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
594}
595
596// ---------------------------------------------------------------------------
597// Fire-and-forget memory save (`POST /api/v1/remember`)
598// ---------------------------------------------------------------------------
599
600/// Request body for `POST /api/v1/remember`.
601///
602/// Why: agents spawned via Claude Code's Agent tool do not inherit any MCP
603/// connections, so the `memory_remember` MCP tool is unreachable to them.
604/// Exposing a plain HTTP entry point lets those agents shell out via the
605/// `trusty-memory note` CLI (or any `curl` call) without learning MCP.
606/// What: `content` is the drawer body and is required; `palace` falls back
607/// to the daemon's `--palace` default when omitted; `tags` is optional and
608/// passed through verbatim to the underlying handler.
609/// Test: `remember_async_*` tests in this module.
610#[derive(Debug, Deserialize)]
611struct RememberAsyncBody {
612    /// Drawer body. Required.
613    content: String,
614    /// Target palace. When `None`, the daemon's `--palace` default is used;
615    /// callers without a default-palace configured must pass this field or
616    /// the spawned task logs a warning and drops the request.
617    #[serde(default)]
618    palace: Option<String>,
619    /// Optional tag list to attach to the drawer.
620    #[serde(default)]
621    tags: Option<Vec<String>>,
622}
623
624/// Minimum word count for content accepted by `POST /api/v1/remember`.
625///
626/// Why (issue #466): the fire-and-forget endpoint returns `202 Accepted`
627/// immediately and dispatches the write on a detached task. Any content that
628/// the background worker would reject (e.g. too few tokens) caused silent data
629/// loss — the caller believed the memory was stored when it wasn't. Validating
630/// the minimum synchronously turns silent drops into explicit `422` rejections
631/// so callers know immediately that their content was not queued.
632/// What: mirrors `tools::CONTENT_GATE_MIN_WORDS` (4 words) — the same gate
633/// `handle_memory_remember` applies via `content_gate` in the background task.
634/// Test: `remember_async_rejects_short_content`.
635const REMEMBER_MIN_WORDS: usize = 4;
636
637/// `POST /api/v1/remember` — fire-and-forget memory save.
638///
639/// Why: sub-agents spawned via Claude Code's Agent tool have no MCP
640/// connection (MCP servers are not inherited across sub-agent spawns), so
641/// they cannot invoke `mcp__trusty-memory__memory_remember` directly. They
642/// can, however, run shell commands — this endpoint plus the
643/// `trusty-memory note` CLI gives them a writable handle that needs no
644/// MCP plumbing. The contract is one-way: the request is parsed, validated,
645/// and queued on a `tokio::spawn`, then `202 Accepted` is returned
646/// immediately. Failures during the spawned dispatch (palace not found,
647/// content gate skip, redb error) are logged at `warn` but never propagate
648/// back to the caller because the agent has already exited by then.
649/// Issue #466: synchronous validation of obvious rejections (empty content,
650/// fewer than [`REMEMBER_MIN_WORDS`] whitespace-delimited words) now returns
651/// `422 Unprocessable Entity` before queuing so callers receive a clear error
652/// instead of a false `202`. Content that passes the synchronous checks may
653/// still be dropped by the background worker's fuller filter set (blocklist,
654/// dedup, MCP-level token threshold), but those are less predictable from
655/// the HTTP surface.
656/// What: deserialises the body, rejects empty content (400) and sub-threshold
657/// word count (422), then maps `{content, palace, tags}` → `{text, palace,
658/// tags}` (the field names `handle_memory_remember` expects) and dispatches
659/// `memory_remember` from a detached task. Returns `202 Accepted` with
660/// `{"status":"queued"}`.
661/// Test: `remember_async_returns_202_and_persists` (happy path),
662/// `remember_async_rejects_empty_content` (400 input validation), and
663/// `remember_async_rejects_short_content` (422 for sub-word-count content).
664async fn remember_async(
665    State(state): State<AppState>,
666    Json(body): Json<RememberAsyncBody>,
667) -> Result<(StatusCode, Json<Value>), ApiError> {
668    let content = body.content.trim();
669    if content.is_empty() {
670        return Err(ApiError::bad_request(
671            "remember: 'content' must be a non-empty string",
672        ));
673    }
674    // Issue #466: synchronous minimum-word-count guard. Content with fewer
675    // than REMEMBER_MIN_WORDS whitespace-separated tokens would be silently
676    // dropped by the background `content_gate`; reject it here so the caller
677    // sees a 422 immediately rather than a false 202.
678    let word_count = content.split_whitespace().count();
679    if word_count < REMEMBER_MIN_WORDS {
680        return Err(ApiError::unprocessable(format!(
681            "remember: content too short ({word_count} word(s)); \
682             minimum is {REMEMBER_MIN_WORDS} words. \
683             Use memory_note for short curated facts."
684        )));
685    }
686
687    // Build the MCP-shaped args once on the request thread so deserialisation
688    // errors never end up swallowed by the spawned task. `handle_memory_remember`
689    // expects `text` (not `content`), so translate the field name here.
690    let mut args = serde_json::Map::new();
691    args.insert("text".to_string(), Value::String(content.to_string()));
692    if let Some(p) = body.palace {
693        args.insert("palace".to_string(), Value::String(p));
694    }
695    if let Some(tags) = body.tags {
696        args.insert(
697            "tags".to_string(),
698            Value::Array(tags.into_iter().map(Value::String).collect()),
699        );
700    }
701    let args = Value::Object(args);
702
703    let state_for_task = state.clone();
704    tokio::spawn(async move {
705        match crate::tools::dispatch_tool(&state_for_task, "memory_remember", args).await {
706            Ok(v) => {
707                tracing::debug!(target: "trusty_memory::remember_async", result = %v, "queued remember succeeded");
708            }
709            Err(e) => {
710                tracing::warn!(
711                    target: "trusty_memory::remember_async",
712                    error = %format!("{e:#}"),
713                    "queued remember failed (caller already returned 202)",
714                );
715            }
716        }
717    });
718
719    Ok((StatusCode::ACCEPTED, Json(json!({ "status": "queued" }))))
720}
721
722// ---------------------------------------------------------------------------
723// Activity log (issue #96)
724// ---------------------------------------------------------------------------
725
726/// Default page size returned by `GET /api/v1/activity` when the client
727/// omits `limit`. Matches the existing 50-row dashboard window.
728const ACTIVITY_DEFAULT_LIMIT: usize = 50;
729
730/// Hard cap on a single activity-page response.
731///
732/// Why: bounds the per-request work the handler performs and the response
733/// size on the wire. The UI never asks for more than a screen's worth at a
734/// time; this leaves headroom for power users running curl.
735/// What: 500 entries — large enough for ad-hoc inspection without becoming
736/// a DoS lever.
737/// Test: `activity_endpoint_clamps_limit`.
738const ACTIVITY_MAX_LIMIT: usize = 500;
739
740/// Query parameters accepted by `GET /api/v1/activity`.
741///
742/// Why: serde-driven extraction keeps the handler signature small while
743/// validating shape (numeric/ISO timestamps, optional fields). All filter
744/// fields are optional and combine with AND.
745/// What: see [`ActivityFilter`] for the underlying filter semantics.
746/// Test: `activity_endpoint_lists_recent_emits`,
747/// `activity_endpoint_filters_by_source_and_palace`.
748#[derive(Deserialize, Debug, Default)]
749struct ActivityQuery {
750    #[serde(default)]
751    limit: Option<usize>,
752    #[serde(default)]
753    offset: Option<usize>,
754    #[serde(default)]
755    palace: Option<String>,
756    #[serde(default)]
757    source: Option<String>,
758    #[serde(default)]
759    since: Option<String>,
760    #[serde(default)]
761    until: Option<String>,
762}
763
764/// Wire shape for one row in the `GET /api/v1/activity` response.
765///
766/// Why: the persisted `ActivityEntry` carries a JSON-encoded `payload`
767/// string so the schema is decoupled from `DaemonEvent` evolution; we
768/// re-decode the payload to a `Value` here so the UI receives a structured
769/// JSON object instead of a nested escaped string.
770/// What: same fields as `ActivityEntry` except `payload` is the parsed
771/// JSON `Value` (falls back to a string when parse fails).
772/// Test: `activity_endpoint_lists_recent_emits`.
773#[derive(Serialize, Debug)]
774struct ActivityRow {
775    id: u64,
776    timestamp: chrono::DateTime<chrono::Utc>,
777    source: &'static str,
778    #[serde(skip_serializing_if = "Option::is_none")]
779    palace_id: Option<String>,
780    event_type: String,
781    payload: Value,
782}
783
784/// `GET /api/v1/activity` — paginated activity history (issue #96).
785///
786/// Why: the dashboard activity feed (`ActivityFeed.svelte`) used to be a
787/// pure live-stream — opening the UI rendered an empty pane. Returning a
788/// paginated history lets the UI seed the feed on mount and load more on
789/// scroll, then layer the SSE live-tail on top.
790/// What: clamps `limit` to [1, [`ACTIVITY_MAX_LIMIT`]], parses optional
791/// filters, and queries the persistent log. The response shape is
792/// `{ entries: [...], total, limit, offset }` so the UI can decide
793/// whether more rows exist.
794/// Test: `activity_endpoint_lists_recent_emits`,
795/// `activity_endpoint_clamps_limit`,
796/// `activity_endpoint_filters_by_source_and_palace`.
797async fn activity_handler(
798    State(state): State<AppState>,
799    Query(q): Query<ActivityQuery>,
800) -> Result<Json<Value>, ApiError> {
801    let limit = q
802        .limit
803        .unwrap_or(ACTIVITY_DEFAULT_LIMIT)
804        .clamp(1, ACTIVITY_MAX_LIMIT);
805    let offset = q.offset.unwrap_or(0);
806
807    let source = match q.source.as_deref() {
808        Some(s) => match ActivitySource::parse(s) {
809            Some(parsed) => Some(parsed),
810            None => {
811                return Err(ApiError::bad_request(format!(
812                    "unknown source '{s}'; expected one of http, mcp, hook",
813                )))
814            }
815        },
816        None => None,
817    };
818
819    let since = parse_iso_or_bad_request(q.since.as_deref(), "since")?;
820    let until = parse_iso_or_bad_request(q.until.as_deref(), "until")?;
821
822    let filter = ActivityFilter {
823        palace_id: q.palace.filter(|s| !s.is_empty()),
824        source,
825        since,
826        until,
827    };
828
829    let entries = state
830        .activity_log
831        .list(&filter, limit, offset)
832        .map_err(|e| ApiError::internal(format!("activity list: {e:#}")))?;
833    let total = state
834        .activity_log
835        .count()
836        .map_err(|e| ApiError::internal(format!("activity count: {e:#}")))?;
837
838    let rows: Vec<ActivityRow> = entries
839        .into_iter()
840        .map(|e| {
841            let payload = serde_json::from_str::<Value>(&e.payload)
842                .unwrap_or_else(|_| Value::String(e.payload.clone()));
843            ActivityRow {
844                id: e.id,
845                timestamp: e.timestamp,
846                source: e.source.as_str(),
847                palace_id: e.palace_id,
848                event_type: e.event_type,
849                payload,
850            }
851        })
852        .collect();
853
854    Ok(Json(json!({
855        "entries": rows,
856        "total": total,
857        "limit": limit,
858        "offset": offset,
859    })))
860}
861
862/// `POST /api/v1/activity/hook` — ingest a hook firing for the activity feed.
863///
864/// Why: Claude Code's hooks (`UserPromptSubmit` → `prompt-context`,
865/// `SessionStart` → `inbox-check`) run as ephemeral CLI subprocesses with no
866/// in-process access to `AppState`. Without an ingestion endpoint they had no
867/// way to populate the activity feed, which left the TUI feed empty for any
868/// session whose only daemon traffic was hooks. This endpoint accepts the
869/// hook's self-reported payload and forwards it to `state.emit` so the same
870/// persistence + SSE broadcast pipeline that handles `DrawerAdded`/etc. also
871/// covers `HookFired`.
872/// What: deserialises a [`HookEventPayload`], maps it onto a
873/// `DaemonEvent::HookFired` with `source = ActivitySource::Hook`, hands it to
874/// `state.emit`, and returns `204 No Content`. Errors only happen for
875/// malformed JSON — handled by axum's own `Json` rejection.
876/// Test: `hook_activity_endpoint_appends_to_activity_log`.
877async fn hook_activity_handler(
878    State(state): State<AppState>,
879    Json(payload): Json<HookEventPayload>,
880) -> Result<StatusCode, ApiError> {
881    state.emit(DaemonEvent::HookFired {
882        palace_id: payload.palace_id,
883        palace_name: payload.palace_name,
884        hook_type: payload.hook_type,
885        injection_kind: payload.injection_kind,
886        injection_length: payload.injection_length,
887        trigger_prompt_excerpt: payload.trigger_prompt_excerpt,
888        timestamp: chrono::Utc::now(),
889        duration_ms: payload.duration_ms,
890        source: ActivitySource::Hook,
891    });
892    Ok(StatusCode::NO_CONTENT)
893}
894
895/// `POST /rpc` — JSON-RPC 2.0 dispatch endpoint.
896///
897/// Why: the multi-transport refactor needs a single HTTP route that
898/// accepts the same envelopes the UDS transport speaks. Browser
899/// clients that want the new tool surface (or third-party scripts
900/// that prefer JSON-RPC to REST) can POST a request envelope here
901/// and get a response back without learning the per-tool REST
902/// vocabulary. The existing `/api/v1/*` REST routes continue to work
903/// unchanged — this is purely additive.
904/// What: deserialises a [`JsonRpcRequest`] from the request body,
905/// calls [`crate::transport::rpc::dispatch`], and returns the
906/// [`JsonRpcResponse`] as JSON. Always returns HTTP 200 with the
907/// envelope inside (JSON-RPC errors are carried in the `error`
908/// field, not the HTTP status). Returns HTTP 400 only on JSON
909/// deserialisation failure of the outer envelope.
910/// Test: `http_rpc_endpoint_roundtrip` in `web::tests`.
911async fn rpc_handler(
912    State(state): State<AppState>,
913    Json(req): Json<crate::transport::rpc::JsonRpcRequest>,
914) -> Json<crate::transport::rpc::JsonRpcResponse> {
915    let resp = crate::transport::rpc::dispatch(&state, req).await;
916    Json(resp)
917}
918
919/// Extract a [`CreatorInfo`] for an HTTP write request.
920///
921/// Why: every HTTP write path (drawers, messages) must attach
922/// attribution tags so operators can trace which client wrote which
923/// drawer. Centralising the extraction here keeps the `X-Trusty-Client-*`
924/// header contract in one place.
925/// What: pulls `X-Trusty-Client-Name` (default
926/// [`HTTP_DEFAULT_CLIENT`]) and the optional `X-Trusty-Client-Cwd`
927/// header off the request, then builds a `CreatorInfo` with
928/// `source = Http` and the current daemon crate version.
929/// Test: `drawer_creator_attribution_http_default`,
930/// `drawer_creator_attribution_http_header`.
931pub(crate) fn creator_info_from_http(headers: &HeaderMap) -> CreatorInfo {
932    let client = headers
933        .get(X_TRUSTY_CLIENT_NAME)
934        .and_then(|v| v.to_str().ok())
935        .map(|s| s.trim())
936        .filter(|s| !s.is_empty())
937        .unwrap_or(HTTP_DEFAULT_CLIENT)
938        .to_string();
939    let cwd = headers
940        .get(X_TRUSTY_CLIENT_CWD)
941        .and_then(|v| v.to_str().ok())
942        .map(|s| s.to_string())
943        .filter(|s| !s.is_empty());
944    CreatorInfo {
945        client,
946        version: env!("CARGO_PKG_VERSION").to_string(),
947        source: CreatorSource::Http,
948        cwd,
949    }
950}
951
952/// Parse an optional ISO-8601 timestamp string for the activity filter.
953///
954/// Why: the `since` / `until` query params are user-supplied; a bad value
955/// should reject the request with a clear 400 rather than be silently
956/// dropped (which would return seemingly-correct but mis-filtered data).
957/// What: returns `Ok(None)` when the input is `None` or empty;
958/// `Ok(Some(_))` on a parseable RFC 3339 timestamp; `Err(ApiError::bad_request)`
959/// otherwise.
960/// Test: `activity_endpoint_lists_recent_emits` exercises the happy path
961/// (no timestamps); a bad timestamp returns 400 — see manual curl.
962fn parse_iso_or_bad_request(
963    s: Option<&str>,
964    field: &str,
965) -> Result<Option<chrono::DateTime<chrono::Utc>>, ApiError> {
966    match s {
967        None | Some("") => Ok(None),
968        Some(raw) => chrono::DateTime::parse_from_rfc3339(raw)
969            .map(|dt| Some(dt.with_timezone(&chrono::Utc)))
970            .map_err(|e| ApiError::bad_request(format!("invalid {field} (RFC 3339): {e}"))),
971    }
972}
973
974// ---------------------------------------------------------------------------
975// Static asset serving
976// ---------------------------------------------------------------------------
977
978/// Serve any embedded asset; fall back to `index.html` for SPA routes.
979///
980/// Why: Hash-based routing lives client-side, but `/assets/foo.js` etc. must
981/// resolve to the embedded file directly.
982/// What: Looks up the request path under `WebAssets`; if absent, returns
983/// `index.html`. Unknown paths under `/api/` return 404.
984/// Test: `serves_index_html`, `serves_static_asset`, `unknown_api_404`.
985async fn static_handler(req: Request<Body>) -> Response {
986    let path = req.uri().path().trim_start_matches('/').to_string();
987
988    if path.starts_with("api/") {
989        return (StatusCode::NOT_FOUND, "not found").into_response();
990    }
991
992    serve_embedded(&path).unwrap_or_else(|| {
993        // SPA fallback.
994        serve_embedded("index.html")
995            .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
996    })
997}
998
999fn serve_embedded(path: &str) -> Option<Response> {
1000    let path = if path.is_empty() { "index.html" } else { path };
1001    let asset = WebAssets::get(path)?;
1002    let mime = mime_guess::from_path(path).first_or_octet_stream();
1003    let body = Body::from(asset.data.into_owned());
1004    let mut resp = Response::new(body);
1005    resp.headers_mut().insert(
1006        header::CONTENT_TYPE,
1007        HeaderValue::from_str(mime.as_ref())
1008            .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
1009    );
1010    Some(resp)
1011}
1012
1013// ---------------------------------------------------------------------------
1014// /api/v1/status, /api/v1/config
1015// ---------------------------------------------------------------------------
1016
1017pub(crate) use crate::service::StatusPayload;
1018
1019async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
1020    Json(crate::service::MemoryService::new(state).status().await)
1021}
1022
1023#[derive(Serialize)]
1024struct ConfigPayload {
1025    openrouter_configured: bool,
1026    model: String,
1027    data_root: String,
1028}
1029
1030async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
1031    let cfg = load_user_config().unwrap_or_default();
1032    Json(ConfigPayload {
1033        openrouter_configured: !cfg.openrouter_api_key.is_empty(),
1034        model: cfg.openrouter_model,
1035        data_root: state.data_root.display().to_string(),
1036    })
1037}
1038
1039pub(crate) use crate::service::load_user_config;
1040#[allow(unused_imports)]
1041pub(crate) use crate::service::LoadedUserConfig;
1042
1043// ---------------------------------------------------------------------------
1044// /api/v1/palaces
1045// ---------------------------------------------------------------------------
1046
1047pub(crate) use crate::service::{palace_info_from, CreatePalaceBody, PalaceInfo};
1048
1049async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
1050    Ok(Json(
1051        crate::service::MemoryService::new(state)
1052            .list_palaces()
1053            .await?,
1054    ))
1055}
1056
1057async fn create_palace(
1058    State(state): State<AppState>,
1059    Json(body): Json<CreatePalaceBody>,
1060) -> Result<Json<Value>, ApiError> {
1061    let id = crate::service::MemoryService::new(state)
1062        .create_palace(body, ActivitySource::Http)
1063        .await?;
1064    Ok(Json(json!({ "id": id })))
1065}
1066
1067async fn get_palace_handler(
1068    State(state): State<AppState>,
1069    AxumPath(id): AxumPath<String>,
1070) -> Result<Json<PalaceInfo>, ApiError> {
1071    Ok(Json(
1072        crate::service::MemoryService::new(state)
1073            .get_palace(&id)
1074            .await?,
1075    ))
1076}
1077
1078/// Query parameters for `DELETE /api/v1/palaces/{id}`.
1079///
1080/// Why: Issue #180 — `force=true` is the explicit opt-in to delete a
1081/// palace that still has drawers. Defaulting to `false` keeps the
1082/// "must be empty" guard active when callers omit the flag.
1083/// What: a single optional bool that the handler unwraps to `false`.
1084/// Test: `delete_palace_refuses_when_drawers_present`,
1085/// `delete_palace_force_removes_populated_palace`.
1086#[derive(Deserialize, Default)]
1087struct DeletePalaceQuery {
1088    #[serde(default)]
1089    force: Option<bool>,
1090}
1091
1092/// `DELETE /api/v1/palaces/{id}?force=<bool>` — drop an entire palace.
1093///
1094/// Why: Issue #180 — operators need a single call to clean up a palace
1095/// they no longer want. The legacy drawer-by-drawer delete path is too
1096/// noisy and leaves the palace's KG / vector index behind.
1097/// What: delegates to `MemoryService::delete_palace`. Returns
1098/// `204 No Content` on success, `404 Not Found` when the id is unknown,
1099/// and `409 Conflict` when the palace still has drawers and `force` is
1100/// not set. Other failures bubble up as 500.
1101/// Test: `delete_palace_removes_dir_when_empty`,
1102/// `delete_palace_refuses_when_drawers_present`,
1103/// `delete_palace_force_removes_populated_palace`,
1104/// `delete_palace_returns_not_found_for_missing_id`.
1105async fn delete_palace_handler(
1106    State(state): State<AppState>,
1107    AxumPath(id): AxumPath<String>,
1108    Query(q): Query<DeletePalaceQuery>,
1109) -> Result<StatusCode, ApiError> {
1110    crate::service::MemoryService::new(state)
1111        .delete_palace(&id, q.force.unwrap_or(false))
1112        .await?;
1113    Ok(StatusCode::NO_CONTENT)
1114}
1115
1116/// Request body for `PATCH /api/v1/palaces/{id}`.
1117///
1118/// Why: The only mutable palace metadata exposed today is the display name;
1119/// keeping the body to a single field keeps the wire contract obvious and
1120/// lets us extend later without breaking older clients (additive fields
1121/// only). Issue #180 follow-up.
1122/// What: a single required `name` string. Empty / whitespace-only values
1123/// are rejected with 400 by the handler.
1124/// Test: `update_palace_name_renames_palace`,
1125/// `update_palace_name_rejects_empty_name`.
1126#[derive(Deserialize)]
1127struct UpdatePalaceBody {
1128    name: String,
1129}
1130
1131/// `PATCH /api/v1/palaces/{id}` — rename a palace's display name.
1132///
1133/// Why: Issue #180 follow-up — operators need to relabel palaces without
1134/// re-creating them (which would lose all stored drawers / KG / vectors).
1135/// Only the human-readable `name` changes; the directory name (which is the
1136/// palace id) is immutable.
1137/// What: delegates to `MemoryService::update_palace_name_typed`. Returns
1138/// `200 OK` with the updated palace info on success, `404 Not Found` when
1139/// the id is unknown, and `400 Bad Request` when the supplied name is
1140/// empty after trimming.
1141/// Test: `update_palace_name_renames_palace`,
1142/// `update_palace_name_rejects_empty_name`,
1143/// `update_palace_name_returns_not_found_for_missing_id`.
1144async fn update_palace_handler(
1145    State(state): State<AppState>,
1146    AxumPath(id): AxumPath<String>,
1147    Json(body): Json<UpdatePalaceBody>,
1148) -> Result<Json<Value>, ApiError> {
1149    let value = crate::service::MemoryService::new(state)
1150        .update_palace_name_typed(&id, &body.name)
1151        .await?;
1152    Ok(Json(value))
1153}
1154
1155// ---------------------------------------------------------------------------
1156// Drawers
1157// ---------------------------------------------------------------------------
1158
1159pub(crate) use crate::service::{CreateDrawerBody, ListDrawersQuery};
1160
1161async fn list_drawers(
1162    State(state): State<AppState>,
1163    AxumPath(id): AxumPath<String>,
1164    Query(q): Query<ListDrawersQuery>,
1165) -> Result<Json<Value>, ApiError> {
1166    Ok(Json(
1167        crate::service::MemoryService::new(state)
1168            .list_drawers(&id, q)
1169            .await?,
1170    ))
1171}
1172
1173async fn create_drawer(
1174    State(state): State<AppState>,
1175    AxumPath(id): AxumPath<String>,
1176    headers: HeaderMap,
1177    Json(body): Json<CreateDrawerBody>,
1178) -> Result<Json<Value>, ApiError> {
1179    let creator = creator_info_from_http(&headers);
1180    let drawer_id = crate::service::MemoryService::new(state)
1181        .create_drawer(&id, body, creator, ActivitySource::Http)
1182        .await?;
1183    Ok(Json(json!({ "id": drawer_id })))
1184}
1185
1186async fn delete_drawer(
1187    State(state): State<AppState>,
1188    AxumPath((id, drawer_id)): AxumPath<(String, String)>,
1189) -> Result<StatusCode, ApiError> {
1190    crate::service::MemoryService::new(state)
1191        .delete_drawer(&id, &drawer_id, ActivitySource::Http)
1192        .await?;
1193    Ok(StatusCode::NO_CONTENT)
1194}
1195
1196// Why: this shim previously bridged `tools.rs` callers into the service
1197//      implementation, but issue #226 moved those callers to use
1198//      `crate::service::MemoryService::new(...).aggregate_status_event()`
1199//      directly so the call site does not require the `axum-server`
1200//      feature. Removing the shim eliminates a dead-code warning.
1201
1202// ---------------------------------------------------------------------------
1203// Recall
1204// ---------------------------------------------------------------------------
1205
1206/// Query parameters shared by the per-palace and cross-palace recall endpoints.
1207///
1208/// Why: both `GET /api/v1/palaces/{id}/recall` and `GET /api/v1/recall` accept
1209/// the same `q` / `top_k` / `deep` triple. Keeping one struct avoids drift
1210/// between the two handler signatures.
1211/// What: `q` is required; `top_k` and `deep` are optional with handler-side
1212/// defaults (10 and false respectively).
1213/// Test: `recall_all_handler_*` tests in this module.
1214#[derive(Deserialize)]
1215struct RecallQuery {
1216    q: String,
1217    #[serde(default)]
1218    top_k: Option<usize>,
1219    #[serde(default)]
1220    deep: Option<bool>,
1221    /// Issue #465: optional palace filter on the flat `GET /api/v1/recall`
1222    /// endpoint. When supplied, recall is scoped to that palace instead of
1223    /// fanning out across all palaces. Absent → cross-palace fan-out.
1224    #[serde(default)]
1225    palace: Option<String>,
1226}
1227
1228async fn recall_handler(
1229    State(state): State<AppState>,
1230    AxumPath(id): AxumPath<String>,
1231    Query(q): Query<RecallQuery>,
1232) -> Result<Json<Value>, ApiError> {
1233    Ok(Json(
1234        crate::service::MemoryService::new(state)
1235            .recall(&id, &q.q, q.top_k.unwrap_or(10), q.deep.unwrap_or(false))
1236            .await?,
1237    ))
1238}
1239
1240#[allow(unused_imports)]
1241pub(crate) use crate::service::recall_entry_json;
1242
1243/// `GET /api/v1/recall?q=<query>&top_k=<n>&deep=<bool>[&palace=<id>]` — recall
1244/// with optional palace scoping.
1245///
1246/// Why: Agents and dashboard widgets often need the most relevant memories
1247/// regardless of palace boundary; forcing the caller to issue one request per
1248/// palace and merge client-side is both slower (no fan-out) and wrong (no
1249/// dedup/rerank). Serving the merged top-k from the daemon collapses the
1250/// round-trip and reuses the shared embedder singleton.
1251/// Issue #465: the `palace=` query param was silently ignored — this endpoint
1252/// always queried the default palace regardless of the supplied filter, causing
1253/// callers to receive results from the wrong palace. Fix: when `palace=` is
1254/// present and non-empty, route the recall to that specific palace (matching
1255/// the behaviour of `GET /api/v1/palaces/{id}/recall`). When absent, fall back
1256/// to the cross-palace fan-out.
1257/// What: If `palace` query param is set, delegates to `MemoryService::recall`
1258/// for that palace. Otherwise lists all palaces, opens each (skipping any that
1259/// fail to open with a warning), and delegates to `execute_recall_all`. Returns
1260/// a JSON array of `{ palace_id, drawer, score, layer }` entries sorted by
1261/// score descending.
1262/// Test: `recall_all_handler_honors_palace_filter`,
1263/// `recall_all_handler_fans_out_without_palace_param`.
1264async fn recall_all_handler(
1265    State(state): State<AppState>,
1266    Query(q): Query<RecallQuery>,
1267) -> Result<Json<Value>, ApiError> {
1268    // Issue #465: honour the `palace=` query param when present.
1269    if let Some(ref palace_id) = q.palace.filter(|s| !s.is_empty()) {
1270        let value = crate::service::MemoryService::new(state)
1271            .recall(
1272                palace_id,
1273                &q.q,
1274                q.top_k.unwrap_or(10),
1275                q.deep.unwrap_or(false),
1276            )
1277            .await?;
1278        return Ok(Json(value));
1279    }
1280    let value = crate::service::MemoryService::new(state)
1281        .recall_all(&q.q, q.top_k.unwrap_or(10), q.deep.unwrap_or(false))
1282        .await;
1283    if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
1284        return Err(ApiError::internal(err.to_string()));
1285    }
1286    Ok(Json(value))
1287}
1288
1289// ---------------------------------------------------------------------------
1290// Knowledge Graph
1291// ---------------------------------------------------------------------------
1292
1293#[derive(Deserialize)]
1294struct KgQueryParams {
1295    subject: String,
1296}
1297
1298async fn kg_query(
1299    State(state): State<AppState>,
1300    AxumPath(id): AxumPath<String>,
1301    Query(q): Query<KgQueryParams>,
1302) -> Result<Json<Vec<Triple>>, ApiError> {
1303    Ok(Json(
1304        crate::service::MemoryService::new(state)
1305            .kg_query(&id, &q.subject)
1306            .await?,
1307    ))
1308}
1309
1310pub(crate) use crate::service::KgAssertBody;
1311
1312async fn kg_assert(
1313    State(state): State<AppState>,
1314    AxumPath(id): AxumPath<String>,
1315    Json(body): Json<KgAssertBody>,
1316) -> Result<StatusCode, ApiError> {
1317    crate::service::MemoryService::new(state)
1318        .kg_assert(&id, body)
1319        .await?;
1320    Ok(StatusCode::NO_CONTENT)
1321}
1322
1323/// Default page size for KG explorer list endpoints when caller omits `limit`.
1324///
1325/// Why: 50 is large enough to feel responsive in the SPA without dumping a
1326/// full graph in one request; matches the default the spec calls for.
1327const DEFAULT_KG_LIST_LIMIT: usize = 50;
1328
1329/// Hard ceiling on `limit` for KG explorer list endpoints.
1330///
1331/// Why: prevent a misconfigured client from asking the daemon to materialize
1332/// thousands of rows in one go; matches the spec's max=200.
1333const MAX_KG_LIST_LIMIT: usize = 200;
1334
1335fn default_kg_list_limit() -> usize {
1336    DEFAULT_KG_LIST_LIMIT
1337}
1338
1339/// Query parameters for `GET /api/v1/palaces/{id}/kg/subjects`.
1340///
1341/// Why: The KG Explorer's left panel asks for a bounded subject list; `limit`
1342/// is clamped server-side so the SPA cannot accidentally pull the whole graph.
1343/// What: `limit` defaults to [`DEFAULT_KG_LIST_LIMIT`] and is clamped to
1344/// `[1, MAX_KG_LIST_LIMIT]` in the handler.
1345/// Test: indirectly by the KG explorer UI; `kg_list_subjects_returns_distinct`
1346/// in the web tests below covers the happy path.
1347#[derive(Deserialize)]
1348struct KgListSubjectsParams {
1349    #[serde(default = "default_kg_list_limit")]
1350    limit: usize,
1351}
1352
1353/// `GET /api/v1/palaces/{id}/kg/subjects?limit=N` — list distinct active
1354/// subjects.
1355///
1356/// Why: The KG Explorer needs to browse subjects without a prior query (the
1357/// existing `kg_query` endpoint requires one). Surfacing this read on the
1358/// daemon avoids the SPA having to know how to issue SQL.
1359/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1360/// `KnowledgeGraph::list_subjects`. Returns a JSON array of strings.
1361/// Test: `kg_list_subjects_returns_distinct` (web tests).
1362async fn kg_list_subjects(
1363    State(state): State<AppState>,
1364    AxumPath(id): AxumPath<String>,
1365    Query(q): Query<KgListSubjectsParams>,
1366) -> Result<Json<Vec<String>>, ApiError> {
1367    let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1368    Ok(Json(
1369        crate::service::MemoryService::new(state)
1370            .kg_list_subjects(&id, limit)
1371            .await?,
1372    ))
1373}
1374
1375/// `GET /api/v1/palaces/{id}/kg/subjects_with_counts?limit=N` — list distinct
1376/// active subjects with their active-triple counts.
1377///
1378/// Why: The KG Explorer's subject list shows a count badge per subject and
1379/// supports sort-by-count. Returning the grouped counts in a single SQL pass
1380/// is cheaper than issuing one query per subject from the SPA.
1381/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1382/// `KnowledgeGraph::list_subjects_with_counts`. Returns a JSON array of
1383/// `{subject, count}` objects ordered alphabetically.
1384/// Test: indirectly via the KG Explorer UI; the core `list_subjects_with_counts`
1385/// test in `kg.rs` covers the SQL grouping.
1386async fn kg_list_subjects_with_counts(
1387    State(state): State<AppState>,
1388    AxumPath(id): AxumPath<String>,
1389    Query(q): Query<KgListSubjectsParams>,
1390) -> Result<Json<Vec<Value>>, ApiError> {
1391    let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1392    let rows = crate::service::MemoryService::new(state)
1393        .kg_list_subjects_with_counts(&id, limit)
1394        .await?;
1395    let out: Vec<Value> = rows
1396        .into_iter()
1397        .map(|(subject, count)| json!({ "subject": subject, "count": count }))
1398        .collect();
1399    Ok(Json(out))
1400}
1401
1402/// Query parameters for `GET /api/v1/palaces/{id}/kg/all`.
1403///
1404/// Why: The KG Explorer's "All" mode pages through every active triple;
1405/// `limit`+`offset` give the SPA stable prev/next controls.
1406/// What: defaults match `kg_list_subjects` for limit; `offset` defaults to 0.
1407/// Test: `kg_list_all_returns_paginated_triples` (web tests).
1408#[derive(Deserialize)]
1409struct KgListAllParams {
1410    #[serde(default = "default_kg_list_limit")]
1411    limit: usize,
1412    #[serde(default)]
1413    offset: usize,
1414}
1415
1416/// `GET /api/v1/palaces/{id}/kg/all?limit=N&offset=N` — list all active
1417/// triples ordered by `valid_from` descending.
1418///
1419/// Why: The KG Explorer's "All" mode wants a paged view across every active
1420/// triple regardless of subject. The existing `kg_query` requires a subject.
1421/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1422/// `KnowledgeGraph::list_active`. Returns a JSON array of `Triple` objects.
1423/// Test: `kg_list_all_returns_paginated_triples` (web tests).
1424async fn kg_list_all(
1425    State(state): State<AppState>,
1426    AxumPath(id): AxumPath<String>,
1427    Query(q): Query<KgListAllParams>,
1428) -> Result<Json<Vec<Triple>>, ApiError> {
1429    let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1430    Ok(Json(
1431        crate::service::MemoryService::new(state)
1432            .kg_list_all(&id, limit, q.offset)
1433            .await?,
1434    ))
1435}
1436
1437/// `GET /api/v1/palaces/{id}/kg/count` — count of currently-active triples.
1438///
1439/// Why: The KG Explorer header shows a quick "N triples" badge; computing the
1440/// count server-side avoids fetching every triple to count them.
1441/// What: returns `{ "active": N }` where N is `count_active_triples()` on the
1442/// palace's KG.
1443/// Test: indirectly via the same palace counts surfaced on `/api/v1/status`.
1444async fn kg_count(
1445    State(state): State<AppState>,
1446    AxumPath(id): AxumPath<String>,
1447) -> Result<Json<Value>, ApiError> {
1448    let active = crate::service::MemoryService::new(state)
1449        .kg_count(&id)
1450        .await?;
1451    Ok(Json(json!({ "active": active })))
1452}
1453
1454/// Separator byte sequence used inside a URL-safe base64 triple ID.
1455///
1456/// Why: The triple primary key is `(subject, predicate)`. Encoding them as a
1457/// single opaque ID lets the REST path look like `/kg/triples/<id>` (a
1458/// resource identifier) rather than carrying both parts in the URL path, which
1459/// would require double-escaping arbitrary strings. A `\0` separator is safe
1460/// because neither subjects nor predicates ever contain null bytes.
1461/// What: Used by [`encode_triple_id`] and [`decode_triple_id`].
1462/// Test: `decode_triple_id_round_trips`.
1463const TRIPLE_ID_SEPARATOR: u8 = 0x00;
1464
1465/// Encode a `(subject, predicate)` pair as a URL-safe base64 triple ID.
1466///
1467/// Why: Produces a single opaque string that can travel as a URL path segment
1468/// without percent-encoding. The null-byte separator ensures the encoding is
1469/// injective (no two distinct pairs can produce the same encoded string).
1470/// What: `base64url(subject_bytes + "\0" + predicate_bytes)`, no padding.
1471/// Test: `decode_triple_id_round_trips`.
1472// Only used in tests (for round-trip assertions); suppress the dead_code lint
1473// that fires in non-test builds because `pub(crate)` alone doesn't silence it.
1474#[allow(dead_code)]
1475pub(crate) fn encode_triple_id(subject: &str, predicate: &str) -> String {
1476    use base64::Engine as _;
1477    let mut buf = Vec::with_capacity(subject.len() + 1 + predicate.len());
1478    buf.extend_from_slice(subject.as_bytes());
1479    buf.push(TRIPLE_ID_SEPARATOR);
1480    buf.extend_from_slice(predicate.as_bytes());
1481    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
1482}
1483
1484/// Decode a URL-safe base64 triple ID back to `(subject, predicate)`.
1485///
1486/// Why: The handler for `DELETE /kg/triples/<id>` needs to recover the
1487/// `(subject, predicate)` pair from the opaque path segment to call the
1488/// service layer.
1489/// What: Decodes base64url, splits on the first null byte. Returns `None`
1490/// when the input is not valid base64url or contains no null separator.
1491/// Test: `decode_triple_id_round_trips`.
1492pub(crate) fn decode_triple_id(id: &str) -> Option<(String, String)> {
1493    use base64::Engine as _;
1494    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1495        .decode(id)
1496        .ok()?;
1497    let sep_pos = bytes.iter().position(|&b| b == TRIPLE_ID_SEPARATOR)?;
1498    let subject = String::from_utf8(bytes[..sep_pos].to_vec()).ok()?;
1499    let predicate = String::from_utf8(bytes[sep_pos + 1..].to_vec()).ok()?;
1500    Some((subject, predicate))
1501}
1502
1503/// `DELETE /api/v1/palaces/{id}/kg/triples/{triple_id}` — surgically remove
1504/// one active triple by its opaque base64url-encoded `(subject, predicate)` ID.
1505///
1506/// Why: Issue #278 — the existing `(subject, predicate)` retract via
1507/// `/kg/prompt-facts` is scope-wide (retract across all palaces). This
1508/// endpoint targets exactly one triple in exactly one palace, giving callers
1509/// a surgical way to delete a specific edge without affecting other palaces
1510/// or other predicates for the same subject.
1511/// What: Decodes `triple_id` (base64url of `subject\0predicate`) back into
1512/// `(subject, predicate)`, retracts the active interval via
1513/// `MemoryService::kg_retract_triple`, and returns:
1514///   - `204 No Content` on success
1515///   - `404 Not Found` when the triple_id is malformed or no active triple
1516///     matched
1517///
1518/// Test: `kg_delete_triple_returns_204_on_success` and
1519/// `kg_delete_triple_returns_404_for_missing`.
1520async fn kg_delete_triple(
1521    State(state): State<AppState>,
1522    AxumPath((id, triple_id)): AxumPath<(String, String)>,
1523) -> Result<StatusCode, ApiError> {
1524    let (subject, predicate) = decode_triple_id(&triple_id).ok_or_else(|| {
1525        ApiError::not_found("invalid triple id — expected base64url(subject\\0predicate)")
1526    })?;
1527    let found = crate::service::MemoryService::new(state)
1528        .kg_retract_triple(&id, &subject, &predicate)
1529        .await?;
1530    if found {
1531        Ok(StatusCode::NO_CONTENT)
1532    } else {
1533        Err(ApiError::not_found(format!(
1534            "no active triple with subject={subject:?} predicate={predicate:?} in palace {id:?}"
1535        )))
1536    }
1537}
1538
1539pub(crate) use crate::service::KgGraphPayload;
1540
1541async fn kg_graph(
1542    State(state): State<AppState>,
1543    AxumPath(id): AxumPath<String>,
1544) -> Result<Json<KgGraphPayload>, ApiError> {
1545    Ok(Json(
1546        crate::service::MemoryService::new(state)
1547            .kg_graph(&id)
1548            .await?,
1549    ))
1550}
1551
1552// ---------------------------------------------------------------------------
1553// Dream cycle status + on-demand run
1554// ---------------------------------------------------------------------------
1555
1556pub(crate) use crate::service::DreamStatusPayload;
1557
1558async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
1559    Json(
1560        crate::service::MemoryService::new(state)
1561            .dream_status_aggregate()
1562            .await,
1563    )
1564}
1565
1566async fn palace_dream_status(
1567    State(state): State<AppState>,
1568    AxumPath(id): AxumPath<String>,
1569) -> Result<Json<DreamStatusPayload>, ApiError> {
1570    Ok(Json(
1571        crate::service::MemoryService::new(state)
1572            .dream_status_for_palace(&id)
1573            .await?,
1574    ))
1575}
1576
1577async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
1578    Ok(Json(
1579        crate::service::MemoryService::new(state)
1580            .dream_run()
1581            .await?,
1582    ))
1583}
1584
1585// ---------------------------------------------------------------------------
1586// Knowledge gaps — community detection cache (issue #53)
1587// ---------------------------------------------------------------------------
1588
1589/// Wire shape for a single knowledge gap returned by `/api/v1/kg/gaps`.
1590///
1591/// Why: `KnowledgeGap` (in `trusty-common`) does not derive `Serialize`
1592/// because that would force serde into the memory-core feature surface; the
1593/// HTTP layer instead owns a narrow response struct mirroring its fields.
1594/// What: One-for-one wire representation of `KnowledgeGap` — entities, the
1595/// internal-density score, the cross-community bridge count, and the
1596/// LLM/template exploration hint.
1597/// Test: `kg_gaps_endpoint_returns_cached_gaps`.
1598#[derive(Serialize, Debug, Clone)]
1599pub struct KnowledgeGapResponse {
1600    pub entities: Vec<String>,
1601    pub internal_density: f32,
1602    pub external_bridges: usize,
1603    pub suggested_exploration: String,
1604}
1605
1606impl From<KnowledgeGap> for KnowledgeGapResponse {
1607    fn from(g: KnowledgeGap) -> Self {
1608        Self {
1609            entities: g.entities,
1610            internal_density: g.internal_density,
1611            external_bridges: g.external_bridges,
1612            suggested_exploration: g.suggested_exploration,
1613        }
1614    }
1615}
1616
1617#[derive(Deserialize)]
1618struct KgGapsQuery {
1619    #[serde(default)]
1620    palace: Option<String>,
1621}
1622
1623/// `GET /api/v1/kg/gaps?palace=<name>` — return the cached knowledge gaps.
1624///
1625/// Why: Issue #53 — surfaces the community-detection output computed by the
1626/// dream cycle so callers (dashboard, MCP tool, external tooling) can list
1627/// the sparse-cluster targets the model should explore next. Reading from
1628/// the in-memory cache means a `/kg/gaps` request never triggers a Louvain
1629/// run; it just clones the latest snapshot.
1630/// What: Resolves the palace from the optional `palace` query arg (falling
1631/// back to the daemon's `default_palace`, then erroring with 400 if neither
1632/// is set). Returns `[]` when the cache has no entry yet — the dream cycle
1633/// simply hasn't populated it. Returns 404 only when the palace name is
1634/// unknown to the registry (handle.open failed).
1635/// Test: `kg_gaps_endpoint_returns_cached_gaps`,
1636/// `kg_gaps_endpoint_returns_empty_when_uncached`.
1637async fn kg_gaps_handler(
1638    State(state): State<AppState>,
1639    Query(q): Query<KgGapsQuery>,
1640) -> Result<Json<Vec<KnowledgeGapResponse>>, ApiError> {
1641    let palace_name = q
1642        .palace
1643        .clone()
1644        .or_else(|| state.default_palace.clone())
1645        .ok_or_else(|| {
1646            ApiError::bad_request("missing 'palace' query parameter (no default palace configured)")
1647        })?;
1648
1649    // Validate the palace exists; we don't strictly need the handle for the
1650    // cache lookup but we want a 404 rather than an empty-array masking a
1651    // typo in the palace name.
1652    let _handle = open_handle(&state, &palace_name)?;
1653
1654    let pid = PalaceId::new(&palace_name);
1655    let gaps = state.registry.get_gaps(&pid).unwrap_or_default();
1656    let body: Vec<KnowledgeGapResponse> =
1657        gaps.into_iter().map(KnowledgeGapResponse::from).collect();
1658    Ok(Json(body))
1659}
1660
1661// ---------------------------------------------------------------------------
1662// Prompt-facts surface (issue #42)
1663// ---------------------------------------------------------------------------
1664
1665/// Query parameters shared by the prompt-context / prompt-facts read endpoints.
1666///
1667/// Why: Both `GET /api/v1/kg/prompt-context` and `GET /api/v1/kg/prompt-facts`
1668/// optionally accept a `palace` filter so callers can scope reads to a single
1669/// project namespace. A shared struct keeps the wire shape consistent.
1670/// What: A single optional `palace` query parameter. When omitted, handlers
1671/// span every palace in the registry (matching the MCP tool behaviour).
1672/// Test: `prompt_context_endpoint_returns_formatted_block`,
1673/// `list_prompt_facts_endpoint_returns_hot_triples`.
1674#[derive(Deserialize)]
1675struct PromptFactsQuery {
1676    // Accepted for forward-compat with the MCP tool surface, but ignored:
1677    // the prompt cache is registry-wide, so reads always span every palace.
1678    // We keep the field rather than ignoring `?palace=...` silently so a
1679    // future per-palace filter is a non-breaking schema addition.
1680    #[serde(default)]
1681    #[allow(dead_code)]
1682    palace: Option<String>,
1683}
1684
1685/// Wire shape for `POST /api/v1/kg/aliases`.
1686///
1687/// Why: Mirrors the `add_alias` MCP tool: a short → full mapping with an
1688/// optional palace target. Keeping the field names identical between the
1689/// HTTP and MCP surfaces makes documentation and client code reuse trivial.
1690/// What: Required `short` and `full`; optional `palace` (falls back to the
1691/// daemon default).
1692/// Test: `add_alias_endpoint_asserts_triple_and_refreshes_cache`.
1693#[derive(Deserialize)]
1694struct AddAliasRequest {
1695    short: String,
1696    full: String,
1697    #[serde(default)]
1698    palace: Option<String>,
1699}
1700
1701/// Wire shape for a single hot-predicate triple in JSON responses.
1702///
1703/// Why: `list_prompt_facts` returns a structured array rather than the
1704/// pre-formatted Markdown so dashboards and tooling can render their own
1705/// views over the raw data.
1706/// What: subject/predicate/object string trio matching the underlying KG row.
1707/// Test: `list_prompt_facts_endpoint_returns_hot_triples`.
1708#[derive(Serialize)]
1709struct PromptFactRow {
1710    subject: String,
1711    predicate: String,
1712    object: String,
1713}
1714
1715/// Query parameters for `DELETE /api/v1/kg/prompt-facts`.
1716///
1717/// Why: The MCP tool retracts the active interval for a `(subject, predicate)`
1718/// pair across every palace; the HTTP endpoint matches that contract so a
1719/// dashboard "Remove" button doesn't need to know which palace owns the fact.
1720/// What: Required `subject` and `predicate`; the issue spec mentions an
1721/// optional `object` filter but the underlying `KnowledgeGraph::retract` API
1722/// closes the entire `(subject, predicate)` interval — we accept `object`
1723/// for forward-compat but currently ignore it, mirroring the MCP tool.
1724/// Test: `remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache`.
1725#[derive(Deserialize)]
1726struct RemovePromptFactQuery {
1727    subject: String,
1728    predicate: String,
1729    #[serde(default)]
1730    #[allow(dead_code)]
1731    object: Option<String>,
1732    #[serde(default)]
1733    #[allow(dead_code)]
1734    palace: Option<String>,
1735}
1736
1737/// `GET /api/v1/kg/prompt-context` — return the formatted prompt-context block.
1738///
1739/// Why: Lets non-MCP callers (the admin UI, curl, integration tests) fetch
1740/// the same Markdown block the `get_prompt_context` tool returns, without
1741/// needing to speak JSON-RPC. The body is a plain text response so it can
1742/// be piped straight into a model prompt.
1743/// What: Reads the in-memory `prompt_context_cache` (already kept fresh by
1744/// any write that touches a hot predicate), returns the formatted string,
1745/// or a placeholder message when nothing has been stored yet.
1746/// Test: `prompt_context_endpoint_returns_formatted_block`.
1747async fn prompt_context_handler(
1748    State(state): State<AppState>,
1749    Query(_q): Query<PromptFactsQuery>,
1750) -> Result<Response, ApiError> {
1751    let cache_snapshot = {
1752        let guard = state.prompt_context_cache.read().await;
1753        guard.clone()
1754    };
1755    let body = if cache_snapshot.formatted.is_empty() {
1756        "No prompt facts stored yet.".to_string()
1757    } else {
1758        cache_snapshot.formatted
1759    };
1760    let mut resp = body.into_response();
1761    resp.headers_mut().insert(
1762        header::CONTENT_TYPE,
1763        HeaderValue::from_static("text/plain; charset=utf-8"),
1764    );
1765    Ok(resp)
1766}
1767
1768/// `POST /api/v1/kg/aliases` — assert a `(short, is_alias_for, full)` triple.
1769///
1770/// Why: HTTP counterpart to the `add_alias` MCP tool — lets the admin UI
1771/// (or an external automation) register aliases without speaking JSON-RPC.
1772/// What: Resolves the target palace (request body → daemon default), opens
1773/// the palace handle, asserts the alias triple, and rebuilds the prompt
1774/// cache so subsequent `GET /api/v1/kg/prompt-context` calls reflect the
1775/// write immediately.
1776/// Test: `add_alias_endpoint_asserts_triple_and_refreshes_cache`.
1777async fn add_alias_handler(
1778    State(state): State<AppState>,
1779    Json(req): Json<AddAliasRequest>,
1780) -> Result<Json<Value>, ApiError> {
1781    if req.short.is_empty() || req.full.is_empty() {
1782        return Err(ApiError::bad_request("short and full are required"));
1783    }
1784    let palace_name = req
1785        .palace
1786        .clone()
1787        .or_else(|| state.default_palace.clone())
1788        .ok_or_else(|| ApiError::bad_request("missing 'palace' (no default palace configured)"))?;
1789    let handle = open_handle(&state, &palace_name)?;
1790    let triple = Triple {
1791        subject: req.short.clone(),
1792        predicate: "is_alias_for".to_string(),
1793        object: req.full.clone(),
1794        valid_from: chrono::Utc::now(),
1795        valid_to: None,
1796        confidence: 1.0,
1797        provenance: Some("add_alias_http".to_string()),
1798    };
1799    handle
1800        .kg
1801        .assert(triple)
1802        .await
1803        .map_err(|e| ApiError::internal(format!("kg.assert failed: {e:#}")))?;
1804    if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1805        tracing::warn!("rebuild_prompt_cache after HTTP add_alias failed: {e:#}");
1806    }
1807    Ok(Json(json!({
1808        "subject": req.short,
1809        "predicate": "is_alias_for",
1810        "object": req.full,
1811        "palace": palace_name,
1812    })))
1813}
1814
1815/// `GET /api/v1/kg/prompt-facts` — list every active hot-predicate triple.
1816///
1817/// Why: Mirrors the `list_prompt_facts` MCP tool. Returning the raw triples
1818/// (rather than the formatted block) lets dashboards group, search, and
1819/// edit them with their own UI.
1820/// What: Calls `gather_hot_triples` over the live registry and serialises
1821/// each row as `{subject, predicate, object}`.
1822/// Test: `list_prompt_facts_endpoint_returns_hot_triples`.
1823async fn list_prompt_facts_handler(
1824    State(state): State<AppState>,
1825    Query(_q): Query<PromptFactsQuery>,
1826) -> Result<Json<Vec<PromptFactRow>>, ApiError> {
1827    let triples = crate::prompt_facts::gather_hot_triples(&state)
1828        .await
1829        .map_err(|e| ApiError::internal(format!("gather_hot_triples: {e:#}")))?;
1830    let rows: Vec<PromptFactRow> = triples
1831        .into_iter()
1832        .map(|(subject, predicate, object)| PromptFactRow {
1833            subject,
1834            predicate,
1835            object,
1836        })
1837        .collect();
1838    Ok(Json(rows))
1839}
1840
1841/// `DELETE /api/v1/kg/prompt-facts?subject=...&predicate=...` — soft-delete
1842/// the active triple matching the given `(subject, predicate)` pair.
1843///
1844/// Why: HTTP counterpart to the `remove_prompt_fact` MCP tool. Mirrors the
1845/// retract-across-palaces semantics so a single call cleans up the fact
1846/// regardless of which palace stored it.
1847/// What: Iterates every palace, calls `kg.retract(subject, predicate)`, and
1848/// reports the total number of intervals closed. Rebuilds the prompt cache
1849/// when at least one retraction occurred.
1850/// Test: `remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache`.
1851async fn remove_prompt_fact_handler(
1852    State(state): State<AppState>,
1853    Query(q): Query<RemovePromptFactQuery>,
1854) -> Result<Json<Value>, ApiError> {
1855    if q.subject.is_empty() || q.predicate.is_empty() {
1856        return Err(ApiError::bad_request("subject and predicate are required"));
1857    }
1858    let mut closed_total: usize = 0;
1859    for palace_id in state.registry.list() {
1860        if let Some(handle) = state.registry.get(&palace_id) {
1861            match handle.kg.retract(&q.subject, &q.predicate).await {
1862                Ok(n) => closed_total += n,
1863                Err(e) => tracing::warn!(
1864                    palace = %palace_id.as_str(),
1865                    "HTTP retract failed: {e:#}",
1866                ),
1867            }
1868        }
1869    }
1870    if closed_total > 0 {
1871        if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1872            tracing::warn!("rebuild_prompt_cache after HTTP remove_prompt_fact failed: {e:#}");
1873        }
1874        Ok(Json(json!({"removed": true, "closed": closed_total})))
1875    } else {
1876        Ok(Json(json!({"removed": false, "reason": "not found"})))
1877    }
1878}
1879
1880#[allow(unused_imports)]
1881pub(crate) use crate::service::refresh_gaps_cache;
1882
1883// ---------------------------------------------------------------------------
1884// Helpers
1885// ---------------------------------------------------------------------------
1886
1887pub(crate) fn open_handle(
1888    state: &AppState,
1889    id: &str,
1890) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
1891    state
1892        .registry
1893        .open_palace(&state.data_root, &PalaceId::new(id))
1894        .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
1895}
1896
1897/// Lightweight error type for HTTP handlers.
1898pub(crate) struct ApiError {
1899    status: StatusCode,
1900    message: String,
1901}
1902
1903impl ApiError {
1904    pub(crate) fn bad_request(msg: impl Into<String>) -> Self {
1905        Self {
1906            status: StatusCode::BAD_REQUEST,
1907            message: msg.into(),
1908        }
1909    }
1910    pub(crate) fn not_found(msg: impl Into<String>) -> Self {
1911        Self {
1912            status: StatusCode::NOT_FOUND,
1913            message: msg.into(),
1914        }
1915    }
1916    /// Build a 409 Conflict response.
1917    ///
1918    /// Why: `DELETE /palaces/{id}` (issue #180) returns 409 when the
1919    /// palace still has drawers and `force=true` is not set. A 400 would
1920    /// be misleading (the request is well-formed) and 404 would lie about
1921    /// existence.
1922    /// What: wraps the message with `StatusCode::CONFLICT`.
1923    /// Test: `delete_palace_refuses_when_drawers_present`.
1924    #[allow(dead_code)]
1925    pub(crate) fn conflict(msg: impl Into<String>) -> Self {
1926        Self {
1927            status: StatusCode::CONFLICT,
1928            message: msg.into(),
1929        }
1930    }
1931    pub(crate) fn internal(msg: impl Into<String>) -> Self {
1932        Self {
1933            status: StatusCode::INTERNAL_SERVER_ERROR,
1934            message: msg.into(),
1935        }
1936    }
1937    /// Build a 422 Unprocessable Entity response.
1938    ///
1939    /// Why (issue #466): content that is structurally valid JSON but fails
1940    /// semantic validation (e.g. too few words to be worth storing) should
1941    /// return 422 rather than 400 (which implies malformed input) or 200/202
1942    /// (which would imply success). 422 is the standard HTTP status for
1943    /// "request understood but semantically unacceptable".
1944    /// What: wraps the message with `StatusCode::UNPROCESSABLE_ENTITY`.
1945    /// Test: `remember_async_rejects_short_content`.
1946    pub(crate) fn unprocessable(msg: impl Into<String>) -> Self {
1947        Self {
1948            status: StatusCode::UNPROCESSABLE_ENTITY,
1949            message: msg.into(),
1950        }
1951    }
1952}
1953
1954impl IntoResponse for ApiError {
1955    fn into_response(self) -> Response {
1956        (self.status, Json(json!({ "error": self.message }))).into_response()
1957    }
1958}
1959
1960impl From<crate::service::ServiceError> for ApiError {
1961    fn from(e: crate::service::ServiceError) -> Self {
1962        match e {
1963            crate::service::ServiceError::BadRequest(m) => ApiError::bad_request(m),
1964            crate::service::ServiceError::NotFound(m) => ApiError::not_found(m),
1965            crate::service::ServiceError::Conflict(m) => ApiError::conflict(m),
1966            crate::service::ServiceError::Internal(m) => ApiError::internal(m),
1967        }
1968    }
1969}
1970
1971#[cfg(test)]
1972mod tests {
1973    use super::*;
1974    // Why (issue #226): `drawer_content_preview` is now used via the
1975    //      axum-free `service` path; the test still validates the same
1976    //      helper, so we import directly to keep the assertions intact.
1977    use crate::service::drawer_content_preview;
1978    use crate::service::DRAWER_PREVIEW_MAX_CHARS;
1979    use axum::body::to_bytes;
1980    use axum::http::Request;
1981    use tower::util::ServiceExt;
1982    use trusty_common::memory_core::palace::Palace;
1983    use trusty_common::memory_core::retrieval::RecallResult;
1984
1985    fn test_state() -> AppState {
1986        let tmp = tempfile::tempdir().expect("tempdir");
1987        let root = tmp.path().to_path_buf();
1988        std::mem::forget(tmp);
1989        // Issue #88: bypass the project-slug enforcement gate so tests can
1990        // create palaces with arbitrary names without having a real project
1991        // root on disk. The env var is harmless once set to "1" because all
1992        // tests in this process use the same setting.
1993        // SAFETY: no other thread reads/writes this var concurrently — the
1994        // const value "1" is idempotent and the write happens before any
1995        // test that creates a palace via the HTTP layer.
1996        unsafe {
1997            std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
1998        }
1999        let state = AppState::new(root);
2000        // Pre-existing tests exercise functional paths — flip to Ready so the
2001        // issue #911 warming preflight does not reject them.
2002        state.set_ready();
2003        state
2004    }
2005
2006    #[test]
2007    fn drawer_preview_collapses_whitespace_and_truncates() {
2008        // Short single-line content is returned verbatim.
2009        assert_eq!(drawer_content_preview("hello world"), "hello world");
2010
2011        // Multiline / tab-laden content collapses to single-spaced text.
2012        assert_eq!(
2013            drawer_content_preview("first line\n\nsecond\tline   third"),
2014            "first line second line third"
2015        );
2016
2017        // Leading / trailing whitespace is stripped.
2018        assert_eq!(drawer_content_preview("   padded   "), "padded");
2019
2020        // Empty content yields an empty preview (fallback signal for clients).
2021        assert_eq!(drawer_content_preview(""), "");
2022
2023        // Long content is truncated to DRAWER_PREVIEW_MAX_CHARS with an ellipsis.
2024        let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
2025        let preview = drawer_content_preview(&long);
2026        assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
2027        assert!(preview.ends_with('…'));
2028
2029        // Content right at the limit is not truncated.
2030        let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
2031        assert_eq!(drawer_content_preview(&exact), exact);
2032    }
2033
2034    /// `GET /health` returns HTTP 200 with `status: "ok"` after the
2035    /// round-trip clears every stage against the auto-provisioned probe palace.
2036    ///
2037    /// Why: confirms the JSON contract (`status`, `version`) for monitors that
2038    /// poll `/health`. Marked `#[ignore]` because issue #185 routes the probe
2039    /// through the dedicated palace and `recall_with_default_embedder` loads
2040    /// ONNX — too heavy for the default CI matrix. Run with
2041    /// `cargo test -p trusty-memory -- --include-ignored`.
2042    /// What: Drives `/health` and asserts the basic JSON keys.
2043    /// Test: this test.
2044    #[tokio::test]
2045    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
2046    async fn health_endpoint_returns_ok() {
2047        let state = test_state();
2048        let app = router().with_state(state);
2049        let resp = app
2050            .oneshot(
2051                Request::builder()
2052                    .uri("/health")
2053                    .body(Body::empty())
2054                    .unwrap(),
2055            )
2056            .await
2057            .unwrap();
2058        assert_eq!(resp.status(), StatusCode::OK);
2059        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2060        let v: Value = serde_json::from_slice(&bytes).unwrap();
2061        assert_eq!(v["status"], "ok");
2062        assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
2063    }
2064
2065    /// Issue #35 — `GET /health` carries the enriched resource block
2066    /// (`rss_mb`, `disk_bytes`, `cpu_pct`, `uptime_secs`).
2067    ///
2068    /// Why: external probes and the admin UI render these; the JSON contract
2069    /// must remain stable. `rss_mb` is sampled live so it is asserted only
2070    /// for a sane unit, not an exact value. Marked `#[ignore]` because
2071    /// issue #185 makes every `/health` request run the full round-trip and
2072    /// `recall_with_default_embedder` loads the ONNX embedder.
2073    /// What: drives `/health` through the router and asserts every new field
2074    /// deserialises with a plausible value.
2075    /// Test: this test.
2076    #[tokio::test]
2077    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
2078    async fn health_endpoint_includes_resource_fields() {
2079        let state = test_state();
2080        let app = router().with_state(state);
2081        let resp = app
2082            .oneshot(
2083                Request::builder()
2084                    .uri("/health")
2085                    .body(Body::empty())
2086                    .unwrap(),
2087            )
2088            .await
2089            .unwrap();
2090        assert_eq!(resp.status(), StatusCode::OK);
2091        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2092        let v: Value = serde_json::from_slice(&bytes).unwrap();
2093        // rss_mb must be a sane unit (megabytes, not bytes).
2094        let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
2095        assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
2096        // cpu_pct is a non-negative percentage (first sample may be 0.0).
2097        let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
2098        assert!(cpu >= 0.0, "cpu_pct must be non-negative");
2099        // disk ticker has not run in this oneshot test → 0.
2100        assert_eq!(v["disk_bytes"].as_u64(), Some(0));
2101        // uptime_secs is present and a u64.
2102        assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
2103    }
2104
2105    /// Why: the fd-exhaustion gauge must appear in the `/health` response on
2106    /// Unix platforms so operators can monitor fd consumption vs. the ceiling.
2107    /// What: drives `/health` through the router and asserts that `open_fds`
2108    /// and `fd_soft_limit` are present and are non-zero unsigned integers.
2109    /// On non-Unix platforms the fields may be absent (the helpers return None
2110    /// and are skipped in serialisation) — that is acceptable and tested here
2111    /// by not asserting presence, only asserting that when present they are sane.
2112    /// Test: this test.
2113    #[tokio::test]
2114    async fn health_endpoint_includes_fd_gauge() {
2115        let state = test_state();
2116        let app = router().with_state(state);
2117        let resp = app
2118            .oneshot(
2119                Request::builder()
2120                    .uri("/health")
2121                    .body(Body::empty())
2122                    .unwrap(),
2123            )
2124            .await
2125            .unwrap();
2126        assert_eq!(resp.status(), StatusCode::OK);
2127        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2128        let v: Value = serde_json::from_slice(&bytes).unwrap();
2129
2130        // On Unix, both fields must be present and sane.
2131        #[cfg(unix)]
2132        {
2133            let open_fds = v["open_fds"]
2134                .as_u64()
2135                .expect("open_fds must be present on Unix");
2136            assert!(
2137                open_fds > 0,
2138                "open_fds must be > 0 (at least stdin/stdout/stderr)"
2139            );
2140
2141            let limit = v["fd_soft_limit"]
2142                .as_u64()
2143                .expect("fd_soft_limit must be present on Unix");
2144            assert!(limit > 0, "fd_soft_limit must be > 0");
2145
2146            // Sanity: open_fds should be well below the ceiling on test machines.
2147            assert!(
2148                open_fds < limit,
2149                "open_fds ({open_fds}) must be below fd_soft_limit ({limit}) in tests"
2150            );
2151        }
2152    }
2153
2154    /// Issue #71 + #185 — `GET /health` reports `status: "ok"` on a fresh
2155    /// install by auto-provisioning the dedicated probe palace and running
2156    /// the full remember/recall/forget cycle against it.
2157    ///
2158    /// Why: Pre-#185 the handler short-circuited with "no palaces" on a fresh
2159    /// install, so a broken data plane would not surface until a real user
2160    /// created a palace. The dedicated `__health_probe__` palace removes that
2161    /// blind spot: the probe runs from boot. Marked `#[ignore]` because the
2162    /// round-trip now loads the ONNX embedder via `recall_with_default_embedder`,
2163    /// which is too heavy for the default CI matrix — run with
2164    /// `cargo test -p trusty-memory -- --include-ignored` for local verification.
2165    /// What: Drives `/health` through the router with an empty `data_root`
2166    /// and asserts `status == "ok"` (probe palace was auto-created and the
2167    /// round-trip cleared every stage) and the `detail` key is absent.
2168    /// Test: this test.
2169    #[tokio::test]
2170    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
2171    async fn health_endpoint_round_trip_on_fresh_install_is_ok() {
2172        let state = test_state();
2173        let app = router().with_state(state);
2174        let resp = app
2175            .oneshot(
2176                Request::builder()
2177                    .uri("/health")
2178                    .body(Body::empty())
2179                    .unwrap(),
2180            )
2181            .await
2182            .unwrap();
2183        assert_eq!(resp.status(), StatusCode::OK);
2184        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2185        let v: Value = serde_json::from_slice(&bytes).unwrap();
2186        assert_eq!(v["status"], "ok");
2187        assert!(
2188            v.get("detail").is_none() || v["detail"].is_null(),
2189            "fresh-install health must not carry a degraded detail (got {v:?})"
2190        );
2191    }
2192
2193    /// Issue #71 — `GET /health` exercises the full store/recall/forget
2194    /// cycle against the first palace and reports `status: "ok"` on success.
2195    ///
2196    /// Why: The whole point of issue #71 is to catch store/recall
2197    /// regressions at probe time rather than via real client traffic. This
2198    /// test creates a real palace, hits `/health`, and asserts the
2199    /// round-trip path is happy. Marked `#[ignore]` because
2200    /// `recall_with_default_embedder` pulls in the ONNX model and is too
2201    /// heavy for the default CI matrix — run with
2202    /// `cargo test -p trusty-memory -- --include-ignored` for local
2203    /// verification.
2204    /// What: Builds an `AppState` with a tempdir `data_root`, creates a
2205    /// `health-probe-palace` via `registry.create_palace`, hits `/health`,
2206    /// and asserts both the status and the absence of any `detail` field.
2207    /// Test: this test.
2208    #[tokio::test]
2209    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
2210    async fn health_endpoint_round_trip_with_palace_is_ok() {
2211        let state = test_state();
2212        let palace = trusty_common::memory_core::Palace {
2213            id: PalaceId::new("health-probe-palace"),
2214            name: "health-probe-palace".to_string(),
2215            description: None,
2216            created_at: chrono::Utc::now(),
2217            data_dir: state.data_root.join("health-probe-palace"),
2218        };
2219        state
2220            .registry
2221            .create_palace(&state.data_root, palace)
2222            .expect("create_palace");
2223
2224        let app = router().with_state(state);
2225        let resp = app
2226            .oneshot(
2227                Request::builder()
2228                    .uri("/health")
2229                    .body(Body::empty())
2230                    .unwrap(),
2231            )
2232            .await
2233            .unwrap();
2234        assert_eq!(resp.status(), StatusCode::OK);
2235        let bytes = to_bytes(resp.into_body(), 2048).await.unwrap();
2236        let v: Value = serde_json::from_slice(&bytes).unwrap();
2237        assert_eq!(
2238            v["status"], "ok",
2239            "round-trip should succeed against a fresh palace; got {v:?}"
2240        );
2241        assert!(
2242            v.get("detail").is_none() || v["detail"].is_null(),
2243            "successful round-trip must not carry a detail field (got {v:?})"
2244        );
2245    }
2246
2247    /// Issue #185 — the `__health_probe__` palace is hidden from
2248    /// `MemoryService::list_palaces`.
2249    ///
2250    /// Why: The dedicated health-probe palace exists on disk and must keep
2251    /// existing across restarts, but it is an internal implementation detail
2252    /// of `/health` and must never confuse the user (in the admin UI, TUI,
2253    /// chat-tool palace roster, etc.).
2254    /// What: Provisions the probe palace via the same helper the handler uses,
2255    /// confirms the directory exists on disk, then asks
2256    /// `MemoryService::list_palaces` for the user-facing roster and asserts
2257    /// no palace with the reserved id (or any `__`-prefixed id) is returned.
2258    /// Test: this test.
2259    #[tokio::test]
2260    async fn health_probe_palace_is_invisible() {
2261        let state = test_state();
2262        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2263
2264        // The probe palace was persisted under the data root.
2265        assert!(
2266            state.data_root.join(HEALTH_PROBE_PALACE).exists(),
2267            "probe palace directory should be persisted on disk"
2268        );
2269
2270        let service = crate::service::MemoryService::new(state);
2271        let listed = service.list_palaces().await.expect("list_palaces");
2272        assert!(
2273            listed.iter().all(|p| !p.id.starts_with("__")),
2274            "no `__`-prefixed palace may appear in the user-facing list; got {:?}",
2275            listed.iter().map(|p| &p.id).collect::<Vec<_>>()
2276        );
2277        assert!(
2278            !listed.iter().any(|p| p.id == HEALTH_PROBE_PALACE),
2279            "the dedicated `__health_probe__` palace must be invisible; got {:?}",
2280            listed.iter().map(|p| &p.id).collect::<Vec<_>>()
2281        );
2282    }
2283
2284    /// Issue #185 — after a successful round-trip, the probe palace holds
2285    /// zero drawers.
2286    ///
2287    /// Why: The probe must clean up after itself on every success path. If
2288    /// the forget step were ever skipped silently, the probe palace would
2289    /// grow unbounded over time (the original symptom was ~1,420 leaked
2290    /// drawers in `localLLM`). This test pins the post-condition without
2291    /// requiring the heavy ONNX recall — it exercises
2292    /// `run_health_round_trip_inner` with a recall stub that returns a
2293    /// synthetic hit matching the probe drawer id.
2294    /// What: Provisions the probe palace, opens its handle, runs the inner
2295    /// round-trip with a stubbed recall that returns the probe drawer, and
2296    /// asserts the handle's drawer count drops back to zero.
2297    /// Test: this test.
2298    #[tokio::test]
2299    async fn health_probe_cleans_up_on_success() {
2300        use trusty_common::memory_core::Drawer;
2301
2302        let state = test_state();
2303        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2304        let handle = state
2305            .registry
2306            .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2307            .expect("open probe palace");
2308
2309        let result = run_health_round_trip_inner(handle.clone(), move |h, _query| async move {
2310            // Synthesize a hit that points at the most recently stored drawer
2311            // so the round-trip treats this as a successful recall.
2312            let drawers = h.drawers.read();
2313            let last = drawers
2314                .last()
2315                .cloned()
2316                .unwrap_or_else(|| Drawer::new(Uuid::new_v4(), "stub"));
2317            drop(drawers);
2318            Ok(vec![RecallResult {
2319                drawer: last,
2320                score: 1.0,
2321                layer: 1,
2322            }])
2323        })
2324        .await;
2325        assert!(
2326            result.is_ok(),
2327            "successful round-trip should return Ok; got {result:?}"
2328        );
2329
2330        let drawer_count = handle.drawers.read().len();
2331        assert_eq!(
2332            drawer_count, 0,
2333            "probe palace must have zero drawers after a successful round-trip (got {drawer_count})"
2334        );
2335    }
2336
2337    /// Issue #185 — when recall returns an empty result, the probe drawer is
2338    /// still deleted before the round-trip surfaces the failure.
2339    ///
2340    /// Why: This is the bug fix's central correctness property. Before #185
2341    /// the empty-result branch did `return Err(RecallMiss)` *before* calling
2342    /// `handle.forget(drawer_id)`, leaking the drawer. The new code calls
2343    /// forget unconditionally and then evaluates the recall outcome, so a
2344    /// recall miss can never leave a drawer behind.
2345    /// What: Drives `run_health_round_trip_inner` with a recall stub that
2346    /// returns an empty `Vec`, asserts the function reports
2347    /// `HealthProbeError::ProbeMissing`, and then asserts the probe palace
2348    /// is empty.
2349    /// Test: this test.
2350    #[tokio::test]
2351    async fn health_probe_cleans_up_on_recall_miss() {
2352        let state = test_state();
2353        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2354        let handle = state
2355            .registry
2356            .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2357            .expect("open probe palace");
2358
2359        let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2360            // Empty result — pre-#185 this leaked the drawer.
2361            Ok(Vec::new())
2362        })
2363        .await;
2364        assert!(
2365            matches!(result, Err(HealthProbeError::ProbeMissing(_))),
2366            "recall miss must surface as ProbeMissing; got {result:?}"
2367        );
2368
2369        let drawer_count = handle.drawers.read().len();
2370        assert_eq!(
2371            drawer_count, 0,
2372            "probe palace must be empty after a recall miss (got {drawer_count})"
2373        );
2374    }
2375
2376    /// Issue #185 — when recall errors out, the probe drawer is still
2377    /// deleted before the round-trip surfaces the failure.
2378    ///
2379    /// Why: The second leak mode pre-#185: `recall` returning `Err(_)` made
2380    /// the function `return Err(Recall(e))` before reaching `forget`. The
2381    /// fix calls forget unconditionally; this test guards that ordering by
2382    /// stubbing a recall that always errors and asserting the palace ends
2383    /// empty.
2384    /// What: Drives `run_health_round_trip_inner` with a recall stub that
2385    /// returns `Err(Recall(...))`, asserts the function surfaces a Recall
2386    /// error, and then asserts the probe palace is empty.
2387    /// Test: this test.
2388    #[tokio::test]
2389    async fn health_probe_cleans_up_on_recall_error() {
2390        let state = test_state();
2391        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2392        let handle = state
2393            .registry
2394            .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2395            .expect("open probe palace");
2396
2397        let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2398            Err(HealthProbeError::Recall("simulated failure".to_string()))
2399        })
2400        .await;
2401        assert!(
2402            matches!(result, Err(HealthProbeError::Recall(_))),
2403            "recall error must surface as Recall; got {result:?}"
2404        );
2405
2406        let drawer_count = handle.drawers.read().len();
2407        assert_eq!(
2408            drawer_count, 0,
2409            "probe palace must be empty after a recall error (got {drawer_count})"
2410        );
2411    }
2412
2413    /// Issue #69 — `recall_entry_json` hoists the drawer's fields to the top
2414    /// level so `content` is directly reachable.
2415    ///
2416    /// Why: The recall API previously wrapped the drawer under a `"drawer"`
2417    /// key, so clients scanning the top level for `content`/`tags` found
2418    /// nothing and recall always looked empty. This locks the flattened shape
2419    /// in place so the regression cannot silently return.
2420    /// What: Builds a `RecallResult`, runs it through `recall_entry_json`, and
2421    /// asserts `content`, `tags`, and `importance` are at the top level, that
2422    /// `score`/`layer` sit alongside them, and that the old `drawer` wrapper
2423    /// key is gone.
2424    /// Test: this test.
2425    #[test]
2426    fn recall_entry_json_hoists_drawer_fields() {
2427        use trusty_common::memory_core::Drawer;
2428
2429        let room = Uuid::new_v4();
2430        let mut drawer = Drawer::new(room, "the answer is 42");
2431        drawer.tags = vec!["source:kuzu".to_string()];
2432        drawer.importance = 0.7;
2433
2434        let entry = recall_entry_json(RecallResult {
2435            drawer,
2436            score: 0.699,
2437            layer: 1,
2438        });
2439
2440        // Content must be reachable WITHOUT a `drawer` wrapper (issue #69).
2441        assert_eq!(
2442            entry.get("content").and_then(|v| v.as_str()),
2443            Some("the answer is 42"),
2444            "content must be at the top level, got {entry:?}"
2445        );
2446        assert!(
2447            entry.get("drawer").is_none(),
2448            "the legacy `drawer` wrapper must not be present, got {entry:?}"
2449        );
2450        // Other drawer fields are hoisted too.
2451        assert_eq!(
2452            entry["importance"].as_f64().map(|f| (f * 10.0).round()),
2453            Some(7.0)
2454        );
2455        assert_eq!(
2456            entry["tags"][0].as_str(),
2457            Some("source:kuzu"),
2458            "tags must be hoisted, got {entry:?}"
2459        );
2460        // Ranking metadata sits alongside the hoisted fields.
2461        assert_eq!(entry["layer"].as_u64(), Some(1));
2462        assert!(
2463            entry["score"]
2464                .as_f64()
2465                .is_some_and(|s| (s - 0.699).abs() < 1e-6),
2466            "score must be preserved, got {entry:?}"
2467        );
2468    }
2469
2470    /// Issue #35 — `GET /api/v1/logs/tail` returns the most recent buffered
2471    /// lines and the total count.
2472    ///
2473    /// Why: operators inspect a running daemon via this endpoint; it must
2474    /// surface exactly what the shared `LogBuffer` holds.
2475    /// What: attaches a `LogBuffer` to the state, pushes three lines, GETs
2476    /// `?n=2`, and asserts the tail + `total`.
2477    /// Test: this test.
2478    #[tokio::test]
2479    async fn logs_tail_returns_recent_lines() {
2480        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2481        buffer.push("line one".to_string());
2482        buffer.push("line two".to_string());
2483        buffer.push("line three".to_string());
2484        let state = test_state().with_log_buffer(buffer);
2485        let app = router().with_state(state);
2486        let resp = app
2487            .oneshot(
2488                Request::builder()
2489                    .uri("/api/v1/logs/tail?n=2")
2490                    .body(Body::empty())
2491                    .unwrap(),
2492            )
2493            .await
2494            .unwrap();
2495        assert_eq!(resp.status(), StatusCode::OK);
2496        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2497        let v: Value = serde_json::from_slice(&bytes).unwrap();
2498        let lines = v["lines"].as_array().expect("lines array");
2499        assert_eq!(lines.len(), 2, "n=2 must return two lines");
2500        assert_eq!(lines[0].as_str(), Some("line two"));
2501        assert_eq!(lines[1].as_str(), Some("line three"));
2502        assert_eq!(v["total"].as_u64(), Some(3));
2503    }
2504
2505    /// Issue #35 — `GET /api/v1/logs/tail?n=` is clamped to
2506    /// `[1, MAX_LOGS_TAIL_N]`.
2507    ///
2508    /// Why: a misconfigured client must not request more lines than the
2509    /// buffer holds, and `n=0` must still return at least one line.
2510    /// What: pushes five lines, requests `n=0` (clamps to 1) and an oversized
2511    /// `n` (clamps to the buffer length).
2512    /// Test: this test.
2513    #[tokio::test]
2514    async fn logs_tail_clamps_n() {
2515        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2516        for i in 0..5 {
2517            buffer.push(format!("l{i}"));
2518        }
2519        let state = test_state().with_log_buffer(buffer);
2520        let app = router().with_state(state);
2521
2522        // n=0 clamps up to 1.
2523        let resp = app
2524            .clone()
2525            .oneshot(
2526                Request::builder()
2527                    .uri("/api/v1/logs/tail?n=0")
2528                    .body(Body::empty())
2529                    .unwrap(),
2530            )
2531            .await
2532            .unwrap();
2533        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2534        let v: Value = serde_json::from_slice(&bytes).unwrap();
2535        assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2536
2537        // n far past MAX clamps down to the buffer length (5).
2538        let resp = app
2539            .oneshot(
2540                Request::builder()
2541                    .uri("/api/v1/logs/tail?n=999999")
2542                    .body(Body::empty())
2543                    .unwrap(),
2544            )
2545            .await
2546            .unwrap();
2547        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2548        let v: Value = serde_json::from_slice(&bytes).unwrap();
2549        assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2550    }
2551
2552    /// Issue #35 — `POST /api/v1/admin/stop` acknowledges the shutdown
2553    /// request with `{ ok, message }`.
2554    ///
2555    /// Why: the response shape is the documented contract for the admin UI's
2556    /// stop button.
2557    /// What: calls `admin_stop` directly and asserts the JSON body. It does
2558    /// NOT await the spawned exit task — that would terminate the test
2559    /// process — but the 200 ms delay before `process::exit` guarantees the
2560    /// test returns first.
2561    /// Test: this test.
2562    #[tokio::test]
2563    async fn admin_stop_returns_ok() {
2564        let state = test_state();
2565        let Json(body) = admin_stop(State(state)).await;
2566        assert_eq!(body["ok"], Value::Bool(true));
2567        assert_eq!(body["message"].as_str(), Some("shutting down"));
2568    }
2569
2570    /// `POST /api/v1/remember` returns 202 Accepted with a `queued` envelope
2571    /// and the spawned task actually persists a drawer in the target palace.
2572    ///
2573    /// Why: this is the central contract of the fire-and-forget endpoint —
2574    /// the response must come back immediately (no waiting on the redb write)
2575    /// and the work must still happen. Without this test the endpoint could
2576    /// silently regress to either "returns 202 but never writes" or "blocks
2577    /// the caller on the dispatch".
2578    /// What: provisions a palace, POSTs `{content, palace, tags}` to the
2579    /// endpoint, asserts 202 + `{status:"queued"}`, then polls the palace's
2580    /// drawer list (up to ~2 s) until the spawned task lands the write.
2581    /// Test: this test.
2582    #[tokio::test]
2583    async fn remember_async_returns_202_and_persists() {
2584        let state = test_state();
2585        // Pre-create the target palace so the spawned task does not race
2586        // against palace_create — we want to assert only the persist path.
2587        let palace = Palace {
2588            id: PalaceId::new("remember-async"),
2589            name: "remember-async".to_string(),
2590            description: None,
2591            created_at: chrono::Utc::now(),
2592            data_dir: state.data_root.join("remember-async"),
2593        };
2594        state
2595            .registry
2596            .create_palace(&state.data_root, palace)
2597            .expect("create_palace");
2598
2599        let app = router().with_state(state.clone());
2600        let body = json!({
2601            "content": "Trusty-memory note CLI ships a fire-and-forget HTTP endpoint for sub-agents.",
2602            "palace": "remember-async",
2603            "tags": ["docs", "note-cli"],
2604        })
2605        .to_string();
2606        let resp = app
2607            .oneshot(
2608                Request::builder()
2609                    .method("POST")
2610                    .uri("/api/v1/remember")
2611                    .header("content-type", "application/json")
2612                    .body(Body::from(body))
2613                    .unwrap(),
2614            )
2615            .await
2616            .unwrap();
2617        assert_eq!(
2618            resp.status(),
2619            StatusCode::ACCEPTED,
2620            "remember endpoint must respond 202 immediately"
2621        );
2622        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2623        let v: Value = serde_json::from_slice(&bytes).unwrap();
2624        assert_eq!(v["status"], "queued");
2625
2626        // Wait for the spawned task to finish. The dedup/blocklist gates run
2627        // on the spawn thread, so we cannot synchronously await the write;
2628        // poll the registry until the drawer lands or the deadline expires.
2629        let handle = state
2630            .registry
2631            .open_palace(&state.data_root, &PalaceId::new("remember-async"))
2632            .expect("open palace");
2633        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2634        loop {
2635            let count = handle.drawers.read().len();
2636            if count >= 1 {
2637                break;
2638            }
2639            if std::time::Instant::now() >= deadline {
2640                panic!("spawned remember task never persisted a drawer (count={count})");
2641            }
2642            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2643        }
2644    }
2645
2646    /// `POST /api/v1/remember` with empty `content` returns 400 — the only
2647    /// synchronous validation the endpoint performs.
2648    ///
2649    /// Why: empty content is a programming error in the caller (the spawned
2650    /// task would just hit the content-gate and silently drop the request),
2651    /// so we surface it as a 400 before queueing. Every other failure mode
2652    /// (palace not found, blocklist, dedup) is logged on the spawn task and
2653    /// still returns 202 because the agent has already exited by then.
2654    /// What: POST `{content: ""}` and assert 400. Also covers the trim path —
2655    /// whitespace-only content is treated as empty.
2656    /// Test: this test.
2657    #[tokio::test]
2658    async fn remember_async_rejects_empty_content() {
2659        let state = test_state();
2660        let app = router().with_state(state);
2661        for body in [json!({"content": ""}), json!({"content": "   \n  "})] {
2662            let resp = app
2663                .clone()
2664                .oneshot(
2665                    Request::builder()
2666                        .method("POST")
2667                        .uri("/api/v1/remember")
2668                        .header("content-type", "application/json")
2669                        .body(Body::from(body.to_string()))
2670                        .unwrap(),
2671                )
2672                .await
2673                .unwrap();
2674            assert_eq!(
2675                resp.status(),
2676                StatusCode::BAD_REQUEST,
2677                "empty content must be rejected; body={body}"
2678            );
2679        }
2680    }
2681
2682    #[tokio::test]
2683    async fn status_endpoint_returns_payload() {
2684        let state = test_state();
2685        let app = router().with_state(state);
2686        let resp = app
2687            .oneshot(
2688                Request::builder()
2689                    .uri("/api/v1/status")
2690                    .body(Body::empty())
2691                    .unwrap(),
2692            )
2693            .await
2694            .unwrap();
2695        assert_eq!(resp.status(), StatusCode::OK);
2696        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2697        let v: Value = serde_json::from_slice(&bytes).unwrap();
2698        assert!(v["version"].is_string());
2699        assert_eq!(v["palace_count"], 0);
2700    }
2701
2702    #[tokio::test]
2703    async fn unknown_api_returns_404() {
2704        let state = test_state();
2705        let app = router().with_state(state);
2706        let resp = app
2707            .oneshot(
2708                Request::builder()
2709                    .uri("/api/v1/does-not-exist")
2710                    .body(Body::empty())
2711                    .unwrap(),
2712            )
2713            .await
2714            .unwrap();
2715        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2716    }
2717
2718    /// Issue #70 — `…/memories` is a working alias for `…/drawers`.
2719    ///
2720    /// Why: Clients that POST/GET against `…/memories` previously hit a 404
2721    /// because only `/drawers` was registered, which silently broke every
2722    /// store call (and pushed callers onto an OOM-prone CLI fallback). The
2723    /// alias must route to the same handler as `/drawers`.
2724    /// What: Creates a real palace via the registry, then GETs the `/memories`
2725    /// alias and asserts a 200 with a JSON array body (the list-drawers shape).
2726    /// Uses GET, not POST, so the test stays embedder-free (no ONNX load).
2727    /// Test: this test.
2728    #[tokio::test]
2729    async fn memories_alias_routes_to_drawers() {
2730        let state = test_state();
2731        let palace = Palace {
2732            id: PalaceId::new("alias-test"),
2733            name: "alias-test".to_string(),
2734            description: None,
2735            created_at: chrono::Utc::now(),
2736            data_dir: state.data_root.join("alias-test"),
2737        };
2738        state
2739            .registry
2740            .create_palace(&state.data_root, palace)
2741            .expect("create_palace");
2742
2743        let app = router().with_state(state);
2744        let resp = app
2745            .oneshot(
2746                Request::builder()
2747                    .uri("/api/v1/palaces/alias-test/memories")
2748                    .body(Body::empty())
2749                    .unwrap(),
2750            )
2751            .await
2752            .unwrap();
2753        assert_eq!(
2754            resp.status(),
2755            StatusCode::OK,
2756            "the /memories alias must resolve to list_drawers, not 404"
2757        );
2758        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2759        let v: Value = serde_json::from_slice(&bytes).unwrap();
2760        assert!(
2761            v.is_array(),
2762            "the alias must return the list-drawers array shape, got {v:?}"
2763        );
2764    }
2765
2766    /// Issue #133 — `POST /api/v1/palaces/{id}/drawers` must trigger the
2767    /// same auto-KG extraction as the MCP `memory_remember` tool.
2768    ///
2769    /// Why: PR #106 wired auto-extract only into the MCP path; HTTP-origin
2770    /// writes silently skipped it, leaving every palace populated via the
2771    /// HTTP API with an empty KG. This regression test posts a drawer over
2772    /// HTTP and then queries the KG to confirm the expected `tag:`,
2773    /// `room:`, and `topic:` (`#hashtag`) auto-extracted triples landed.
2774    /// What: creates a palace via the registry, posts a drawer with tags +
2775    /// room + a `#hashtag` over the HTTP endpoint, reads
2776    /// `/api/v1/palaces/{id}/kg/graph`, and asserts the auto-extracted
2777    /// triples (provenance = `auto:remember`) appear.
2778    /// Test: this test.
2779    #[tokio::test]
2780    async fn http_create_drawer_runs_auto_kg_extraction() {
2781        let state = test_state();
2782        let palace = Palace {
2783            id: PalaceId::new("kgauto-http"),
2784            name: "kgauto-http".to_string(),
2785            description: None,
2786            created_at: chrono::Utc::now(),
2787            data_dir: state.data_root.join("kgauto-http"),
2788        };
2789        state
2790            .registry
2791            .create_palace(&state.data_root, palace)
2792            .expect("create_palace");
2793
2794        let app = router().with_state(state.clone());
2795        // Why: tag "test" is in the KG extraction deny-list (issue #278), so we
2796        // use "backend" and "kg" tags to exercise the auto-extraction path
2797        // without triggering the deny-list skip.
2798        let body = json!({
2799            "content": "trusty-memory is a Rust crate that ships an MCP server. \
2800                        It tracks #mcp and #rust topics with care.",
2801            "room": "Backend",
2802            "tags": ["backend", "kg"],
2803            "importance": 0.5,
2804        })
2805        .to_string();
2806        let resp = app
2807            .clone()
2808            .oneshot(
2809                Request::builder()
2810                    .method("POST")
2811                    .uri("/api/v1/palaces/kgauto-http/drawers")
2812                    .header("content-type", "application/json")
2813                    .body(Body::from(body))
2814                    .unwrap(),
2815            )
2816            .await
2817            .unwrap();
2818        assert_eq!(
2819            resp.status(),
2820            StatusCode::OK,
2821            "create_drawer must return 200 OK"
2822        );
2823
2824        // Read the KG graph for the same palace and assert auto-extracted
2825        // triples landed. The exact set is exercised in
2826        // `tools::tests::auto_kg_extraction_hooks_into_memory_remember`; here
2827        // we only need to confirm the HTTP path now mirrors the MCP path.
2828        let resp = app
2829            .oneshot(
2830                Request::builder()
2831                    .uri("/api/v1/palaces/kgauto-http/kg/graph")
2832                    .body(Body::empty())
2833                    .unwrap(),
2834            )
2835            .await
2836            .unwrap();
2837        assert_eq!(resp.status(), StatusCode::OK);
2838        let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
2839        let v: Value = serde_json::from_slice(&bytes).unwrap();
2840        let triples = v["triples"].as_array().expect("triples array");
2841        assert!(
2842            !triples.is_empty(),
2843            "HTTP-origin drawer must populate the KG; got empty graph"
2844        );
2845        let auto: Vec<&Value> = triples
2846            .iter()
2847            .filter(|t| t["provenance"].as_str() == Some(crate::kg_extract::AUTO_PROVENANCE))
2848            .collect();
2849        assert!(
2850            !auto.is_empty(),
2851            "expected at least one auto-extracted triple in HTTP-populated KG; got: {triples:?}"
2852        );
2853        // Spot-check the tag-as-subject encoding survived (matches the MCP
2854        // path's behaviour and proves the extractor saw the body's tags).
2855        // Note: "test" is in the deny-list, so we use "backend" in the drawer
2856        // tags above (issue #278); assert on that tag instead.
2857        assert!(
2858            auto.iter()
2859                .any(|t| t["subject"].as_str() == Some("tag:backend")),
2860            "expected `tag:backend` auto-extracted edge, got: {auto:?}"
2861        );
2862        // Hashtag mention triples (room-aware extractor).
2863        assert!(
2864            auto.iter()
2865                .any(|t| t["predicate"].as_str() == Some("mentioned-in")),
2866            "expected at least one #hashtag mention triple, got: {auto:?}"
2867        );
2868    }
2869
2870    #[tokio::test]
2871    async fn create_then_list_palace() {
2872        let state = test_state();
2873        let app = router().with_state(state.clone());
2874        let body = json!({"name": "web-test", "description": "from test"}).to_string();
2875        let resp = app
2876            .clone()
2877            .oneshot(
2878                Request::builder()
2879                    .method("POST")
2880                    .uri("/api/v1/palaces")
2881                    .header("content-type", "application/json")
2882                    .body(Body::from(body))
2883                    .unwrap(),
2884            )
2885            .await
2886            .unwrap();
2887        assert_eq!(resp.status(), StatusCode::OK);
2888
2889        let resp = app
2890            .oneshot(
2891                Request::builder()
2892                    .uri("/api/v1/palaces")
2893                    .body(Body::empty())
2894                    .unwrap(),
2895            )
2896            .await
2897            .unwrap();
2898        assert_eq!(resp.status(), StatusCode::OK);
2899        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2900        let v: Value = serde_json::from_slice(&bytes).unwrap();
2901        let arr = v.as_array().expect("array");
2902        assert!(arr.iter().any(|p| p["id"] == "web-test"));
2903    }
2904
2905    /// Why: Issue #180 — verify the happy path: create an empty palace,
2906    /// `DELETE /api/v1/palaces/{id}` returns 204, and a follow-up
2907    /// `GET /api/v1/palaces/{id}` returns 404 because the directory is gone.
2908    /// What: Drives the router through axum's `oneshot` testing layer; no
2909    /// query parameters are passed so `force` defaults to `false`. A freshly
2910    /// created palace has no drawers, so the conflict guard does not fire.
2911    /// Test: This test itself.
2912    #[tokio::test]
2913    async fn delete_palace_removes_dir_when_empty() {
2914        let state = test_state();
2915        let app = router().with_state(state.clone());
2916        let body = json!({"name": "to-delete"}).to_string();
2917        let resp = app
2918            .clone()
2919            .oneshot(
2920                Request::builder()
2921                    .method("POST")
2922                    .uri("/api/v1/palaces")
2923                    .header("content-type", "application/json")
2924                    .body(Body::from(body))
2925                    .unwrap(),
2926            )
2927            .await
2928            .unwrap();
2929        assert_eq!(resp.status(), StatusCode::OK);
2930
2931        let resp = app
2932            .clone()
2933            .oneshot(
2934                Request::builder()
2935                    .method("DELETE")
2936                    .uri("/api/v1/palaces/to-delete")
2937                    .body(Body::empty())
2938                    .unwrap(),
2939            )
2940            .await
2941            .unwrap();
2942        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2943
2944        // Confirm the palace is gone from the on-disk registry.
2945        let resp = app
2946            .oneshot(
2947                Request::builder()
2948                    .uri("/api/v1/palaces/to-delete")
2949                    .body(Body::empty())
2950                    .unwrap(),
2951            )
2952            .await
2953            .unwrap();
2954        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2955
2956        // And the on-disk directory itself was removed.
2957        let palace_dir = state.data_root.join("to-delete");
2958        assert!(
2959            !palace_dir.exists(),
2960            "palace dir should be removed: {}",
2961            palace_dir.display()
2962        );
2963    }
2964
2965    /// Why: Issue #180 — without `force=true` we must refuse to drop a
2966    /// palace that still has drawers, otherwise a stray DELETE could nuke
2967    /// hours of memory in one request.
2968    /// What: Create a palace, write a drawer into it, then DELETE without
2969    /// `force`. Expect 409 Conflict and verify the palace and drawer are
2970    /// still on disk.
2971    /// Test: This test itself.
2972    #[tokio::test]
2973    async fn delete_palace_refuses_when_drawers_present() {
2974        let state = test_state();
2975        let app = router().with_state(state.clone());
2976        // Create the palace.
2977        let resp = app
2978            .clone()
2979            .oneshot(
2980                Request::builder()
2981                    .method("POST")
2982                    .uri("/api/v1/palaces")
2983                    .header("content-type", "application/json")
2984                    .body(Body::from(json!({"name": "keep-me"}).to_string()))
2985                    .unwrap(),
2986            )
2987            .await
2988            .unwrap();
2989        assert_eq!(resp.status(), StatusCode::OK);
2990        // Add a drawer so the conflict guard fires.
2991        let resp = app
2992            .clone()
2993            .oneshot(
2994                Request::builder()
2995                    .method("POST")
2996                    .uri("/api/v1/palaces/keep-me/drawers")
2997                    .header("content-type", "application/json")
2998                    .body(Body::from(
2999                        json!({
3000                            "content": "Important fact that should not be deleted accidentally.",
3001                            "tags": [],
3002                        })
3003                        .to_string(),
3004                    ))
3005                    .unwrap(),
3006            )
3007            .await
3008            .unwrap();
3009        assert_eq!(resp.status(), StatusCode::OK);
3010
3011        let resp = app
3012            .clone()
3013            .oneshot(
3014                Request::builder()
3015                    .method("DELETE")
3016                    .uri("/api/v1/palaces/keep-me")
3017                    .body(Body::empty())
3018                    .unwrap(),
3019            )
3020            .await
3021            .unwrap();
3022        assert_eq!(resp.status(), StatusCode::CONFLICT);
3023
3024        // Palace still resolves.
3025        let resp = app
3026            .oneshot(
3027                Request::builder()
3028                    .uri("/api/v1/palaces/keep-me")
3029                    .body(Body::empty())
3030                    .unwrap(),
3031            )
3032            .await
3033            .unwrap();
3034        assert_eq!(resp.status(), StatusCode::OK);
3035    }
3036
3037    /// Why: Issue #180 — `?force=true` is the explicit destructive opt-in;
3038    /// the conflict guard must yield and the palace must vanish even with
3039    /// drawers present.
3040    /// What: Same setup as the conflict test, but pass `?force=true` and
3041    /// assert the 204 + 404 follow-up shape.
3042    /// Test: This test itself.
3043    #[tokio::test]
3044    async fn delete_palace_force_removes_populated_palace() {
3045        let state = test_state();
3046        let app = router().with_state(state.clone());
3047        let resp = app
3048            .clone()
3049            .oneshot(
3050                Request::builder()
3051                    .method("POST")
3052                    .uri("/api/v1/palaces")
3053                    .header("content-type", "application/json")
3054                    .body(Body::from(json!({"name": "force-delete"}).to_string()))
3055                    .unwrap(),
3056            )
3057            .await
3058            .unwrap();
3059        assert_eq!(resp.status(), StatusCode::OK);
3060        let resp = app
3061            .clone()
3062            .oneshot(
3063                Request::builder()
3064                    .method("POST")
3065                    .uri("/api/v1/palaces/force-delete/drawers")
3066                    .header("content-type", "application/json")
3067                    .body(Body::from(
3068                        json!({"content": "Sacrificial drawer for the force-delete path.", "tags": []}).to_string(),
3069                    ))
3070                    .unwrap(),
3071            )
3072            .await
3073            .unwrap();
3074        assert_eq!(resp.status(), StatusCode::OK);
3075
3076        let resp = app
3077            .clone()
3078            .oneshot(
3079                Request::builder()
3080                    .method("DELETE")
3081                    .uri("/api/v1/palaces/force-delete?force=true")
3082                    .body(Body::empty())
3083                    .unwrap(),
3084            )
3085            .await
3086            .unwrap();
3087        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3088
3089        let resp = app
3090            .oneshot(
3091                Request::builder()
3092                    .uri("/api/v1/palaces/force-delete")
3093                    .body(Body::empty())
3094                    .unwrap(),
3095            )
3096            .await
3097            .unwrap();
3098        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3099    }
3100
3101    /// Why: Issue #180 — deleting a missing palace must yield 404 so
3102    /// idempotent retries on the client are distinguishable from the
3103    /// "drawers present" precondition failure.
3104    /// What: DELETE against a never-created id and assert 404.
3105    /// Test: This test itself.
3106    #[tokio::test]
3107    async fn delete_palace_returns_not_found_for_missing_id() {
3108        let state = test_state();
3109        let app = router().with_state(state);
3110        let resp = app
3111            .oneshot(
3112                Request::builder()
3113                    .method("DELETE")
3114                    .uri("/api/v1/palaces/never-existed")
3115                    .body(Body::empty())
3116                    .unwrap(),
3117            )
3118            .await
3119            .unwrap();
3120        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3121    }
3122
3123    /// Why: Issue #180 follow-up — verify the happy path of `PATCH
3124    /// /api/v1/palaces/{id}`: create a palace, rename it, and confirm
3125    /// `GET /api/v1/palaces/{id}` returns the new display name. The id
3126    /// (which is the on-disk directory) must stay stable.
3127    /// What: POST a palace named "rename-me", PATCH with a new display
3128    /// name, expect 200 + payload showing the rename, then GET to confirm
3129    /// persistence to disk.
3130    /// Test: This test itself.
3131    #[tokio::test]
3132    async fn update_palace_name_renames_palace() {
3133        let state = test_state();
3134        let app = router().with_state(state);
3135        let resp = app
3136            .clone()
3137            .oneshot(
3138                Request::builder()
3139                    .method("POST")
3140                    .uri("/api/v1/palaces")
3141                    .header("content-type", "application/json")
3142                    .body(Body::from(json!({"name": "rename-me"}).to_string()))
3143                    .unwrap(),
3144            )
3145            .await
3146            .unwrap();
3147        assert_eq!(resp.status(), StatusCode::OK);
3148
3149        let resp = app
3150            .clone()
3151            .oneshot(
3152                Request::builder()
3153                    .method("PATCH")
3154                    .uri("/api/v1/palaces/rename-me")
3155                    .header("content-type", "application/json")
3156                    .body(Body::from(json!({"name": "New Display Name"}).to_string()))
3157                    .unwrap(),
3158            )
3159            .await
3160            .unwrap();
3161        assert_eq!(resp.status(), StatusCode::OK);
3162        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3163        let v: Value = serde_json::from_slice(&bytes).unwrap();
3164        assert_eq!(v["id"].as_str(), Some("rename-me"));
3165        assert_eq!(v["name"].as_str(), Some("New Display Name"));
3166
3167        let resp = app
3168            .oneshot(
3169                Request::builder()
3170                    .uri("/api/v1/palaces/rename-me")
3171                    .body(Body::empty())
3172                    .unwrap(),
3173            )
3174            .await
3175            .unwrap();
3176        assert_eq!(resp.status(), StatusCode::OK);
3177        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3178        let v: Value = serde_json::from_slice(&bytes).unwrap();
3179        assert_eq!(v["id"].as_str(), Some("rename-me"));
3180        assert_eq!(v["name"].as_str(), Some("New Display Name"));
3181    }
3182
3183    /// Why: Issue #180 follow-up — empty / whitespace-only names would
3184    /// break the dashboard label. Reject with 400 so the caller knows the
3185    /// request was well-formed but the value is invalid.
3186    /// What: Create a palace, PATCH with `{"name": "   "}`, expect 400.
3187    /// Test: This test itself.
3188    #[tokio::test]
3189    async fn update_palace_name_rejects_empty_name() {
3190        let state = test_state();
3191        let app = router().with_state(state);
3192        let resp = app
3193            .clone()
3194            .oneshot(
3195                Request::builder()
3196                    .method("POST")
3197                    .uri("/api/v1/palaces")
3198                    .header("content-type", "application/json")
3199                    .body(Body::from(json!({"name": "keep-name"}).to_string()))
3200                    .unwrap(),
3201            )
3202            .await
3203            .unwrap();
3204        assert_eq!(resp.status(), StatusCode::OK);
3205
3206        let resp = app
3207            .oneshot(
3208                Request::builder()
3209                    .method("PATCH")
3210                    .uri("/api/v1/palaces/keep-name")
3211                    .header("content-type", "application/json")
3212                    .body(Body::from(json!({"name": "   "}).to_string()))
3213                    .unwrap(),
3214            )
3215            .await
3216            .unwrap();
3217        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
3218    }
3219
3220    /// Why: Issue #180 follow-up — patching a non-existent palace must
3221    /// yield 404 so retries against the wrong id surface the real problem
3222    /// rather than silently no-op'ing.
3223    /// What: PATCH against a never-created id and assert 404.
3224    /// Test: This test itself.
3225    #[tokio::test]
3226    async fn update_palace_name_returns_not_found_for_missing_id() {
3227        let state = test_state();
3228        let app = router().with_state(state);
3229        let resp = app
3230            .oneshot(
3231                Request::builder()
3232                    .method("PATCH")
3233                    .uri("/api/v1/palaces/no-such-palace")
3234                    .header("content-type", "application/json")
3235                    .body(Body::from(json!({"name": "irrelevant"}).to_string()))
3236                    .unwrap(),
3237            )
3238            .await
3239            .unwrap();
3240        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3241    }
3242
3243    /// Why: The operator TUI's MEMORY tab reads `node_count`, `edge_count`,
3244    /// `community_count`, and `is_compacting` straight off the
3245    /// `/api/v1/palaces` payload. If any of those fields disappear or change
3246    /// type the spinner / counters break silently. Pin the shape here.
3247    /// What: Creates a palace, lists `/api/v1/palaces`, and asserts every new
3248    /// field is present and typed as expected (numbers default to 0, the
3249    /// compacting flag defaults to false on a freshly-opened palace).
3250    /// Test: This test itself.
3251    #[tokio::test]
3252    async fn palace_list_includes_graph_counts() {
3253        let state = test_state();
3254        let app = router().with_state(state.clone());
3255        let body = json!({"name": "graph-counts", "description": null}).to_string();
3256        let resp = app
3257            .clone()
3258            .oneshot(
3259                Request::builder()
3260                    .method("POST")
3261                    .uri("/api/v1/palaces")
3262                    .header("content-type", "application/json")
3263                    .body(Body::from(body))
3264                    .unwrap(),
3265            )
3266            .await
3267            .unwrap();
3268        assert_eq!(resp.status(), StatusCode::OK);
3269
3270        let resp = app
3271            .oneshot(
3272                Request::builder()
3273                    .uri("/api/v1/palaces")
3274                    .body(Body::empty())
3275                    .unwrap(),
3276            )
3277            .await
3278            .unwrap();
3279        assert_eq!(resp.status(), StatusCode::OK);
3280        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3281        let v: Value = serde_json::from_slice(&bytes).unwrap();
3282        let arr = v.as_array().expect("array");
3283        let row = arr
3284            .iter()
3285            .find(|p| p["id"] == "graph-counts")
3286            .expect("created palace must appear in list");
3287        assert_eq!(row["node_count"].as_u64(), Some(0));
3288        assert_eq!(row["edge_count"].as_u64(), Some(0));
3289        assert_eq!(row["community_count"].as_u64(), Some(0));
3290        assert_eq!(row["is_compacting"].as_bool(), Some(false));
3291    }
3292
3293    /// Why: The enriched status payload backs the dashboard's top-row stats;
3294    /// it must always include the new total_* counters, even on an empty data
3295    /// root, so the UI can render zeros without special-casing missing fields.
3296    /// What: Hit `/api/v1/status` on a fresh state and assert the new fields
3297    /// are present and set to 0.
3298    /// Test: This test itself.
3299    #[tokio::test]
3300    async fn status_includes_total_counters() {
3301        let state = test_state();
3302        let app = router().with_state(state);
3303        let resp = app
3304            .oneshot(
3305                Request::builder()
3306                    .uri("/api/v1/status")
3307                    .body(Body::empty())
3308                    .unwrap(),
3309            )
3310            .await
3311            .unwrap();
3312        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3313        let v: Value = serde_json::from_slice(&bytes).unwrap();
3314        assert_eq!(v["total_drawers"], 0);
3315        assert_eq!(v["total_vectors"], 0);
3316        assert_eq!(v["total_kg_triples"], 0);
3317    }
3318
3319    /// Why: `/api/v1/dream/status` must return a well-shaped payload even
3320    /// when no palace has ever run a dream cycle (so the dashboard's first
3321    /// load doesn't error).
3322    /// What: Hit the endpoint on a fresh state and assert `last_run_at` is
3323    /// null and the counters are zero.
3324    /// Test: This test itself.
3325    #[tokio::test]
3326    async fn dream_status_empty_returns_nulls() {
3327        let state = test_state();
3328        let app = router().with_state(state);
3329        let resp = app
3330            .oneshot(
3331                Request::builder()
3332                    .uri("/api/v1/dream/status")
3333                    .body(Body::empty())
3334                    .unwrap(),
3335            )
3336            .await
3337            .unwrap();
3338        assert_eq!(resp.status(), StatusCode::OK);
3339        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3340        let v: Value = serde_json::from_slice(&bytes).unwrap();
3341        assert!(v["last_run_at"].is_null());
3342        assert_eq!(v["merged"], 0);
3343        assert_eq!(v["pruned"], 0);
3344    }
3345
3346    /// Why: `/api/v1/chat/providers` must return a well-shaped payload even
3347    /// when no provider is available, so the SPA can render disabled states
3348    /// without special-casing missing fields.
3349    /// What: Hit the endpoint on a fresh state; assert it returns `providers`
3350    /// (an array of length 2) and an `active` field (possibly null).
3351    /// Test: This test itself.
3352    #[tokio::test]
3353    async fn providers_endpoint_returns_payload() {
3354        let state = test_state();
3355        let app = router().with_state(state);
3356        let resp = app
3357            .oneshot(
3358                Request::builder()
3359                    .uri("/api/v1/chat/providers")
3360                    .body(Body::empty())
3361                    .unwrap(),
3362            )
3363            .await
3364            .unwrap();
3365        assert_eq!(resp.status(), StatusCode::OK);
3366        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3367        let v: Value = serde_json::from_slice(&bytes).unwrap();
3368        let arr = v["providers"].as_array().expect("providers array");
3369        assert_eq!(arr.len(), 2);
3370        let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
3371        assert!(names.contains(&"ollama"));
3372        assert!(names.contains(&"openrouter"));
3373        // `active` may be null when no provider is configured/reachable.
3374        assert!(v.get("active").is_some());
3375    }
3376
3377    /// Why: Chat-session CRUD must round-trip end-to-end through the HTTP
3378    /// surface — create returns an id, list shows it, get returns the
3379    /// (empty) history, delete removes it.
3380    /// What: Create a palace, then exercise the four session endpoints
3381    /// sequentially, asserting JSON shapes at each step.
3382    /// Test: This test itself.
3383    #[tokio::test]
3384    async fn chat_session_crud_round_trip() {
3385        let state = test_state();
3386        // Pre-create a palace dir so session store has a place to live.
3387        let palace = trusty_common::memory_core::Palace {
3388            id: PalaceId::new("sess-test"),
3389            name: "sess-test".to_string(),
3390            description: None,
3391            created_at: chrono::Utc::now(),
3392            data_dir: state.data_root.join("sess-test"),
3393        };
3394        state
3395            .registry
3396            .create_palace(&state.data_root, palace)
3397            .expect("create_palace");
3398        let app = router().with_state(state);
3399
3400        // Create
3401        let resp = app
3402            .clone()
3403            .oneshot(
3404                Request::builder()
3405                    .method("POST")
3406                    .uri("/api/v1/palaces/sess-test/chat/sessions")
3407                    .header("content-type", "application/json")
3408                    .body(Body::from(json!({"title":"first chat"}).to_string()))
3409                    .unwrap(),
3410            )
3411            .await
3412            .unwrap();
3413        assert_eq!(resp.status(), StatusCode::OK);
3414        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3415        let v: Value = serde_json::from_slice(&bytes).unwrap();
3416        let sid = v["id"].as_str().expect("session id").to_string();
3417
3418        // List
3419        let resp = app
3420            .clone()
3421            .oneshot(
3422                Request::builder()
3423                    .uri("/api/v1/palaces/sess-test/chat/sessions")
3424                    .body(Body::empty())
3425                    .unwrap(),
3426            )
3427            .await
3428            .unwrap();
3429        assert_eq!(resp.status(), StatusCode::OK);
3430        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3431        let v: Value = serde_json::from_slice(&bytes).unwrap();
3432        let arr = v.as_array().expect("array");
3433        assert!(arr.iter().any(|s| s["id"] == sid));
3434
3435        // Get
3436        let resp = app
3437            .clone()
3438            .oneshot(
3439                Request::builder()
3440                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3441                    .body(Body::empty())
3442                    .unwrap(),
3443            )
3444            .await
3445            .unwrap();
3446        assert_eq!(resp.status(), StatusCode::OK);
3447
3448        // Delete
3449        let resp = app
3450            .clone()
3451            .oneshot(
3452                Request::builder()
3453                    .method("DELETE")
3454                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3455                    .body(Body::empty())
3456                    .unwrap(),
3457            )
3458            .await
3459            .unwrap();
3460        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3461
3462        // Get after delete -> 404
3463        let resp = app
3464            .oneshot(
3465                Request::builder()
3466                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3467                    .body(Body::empty())
3468                    .unwrap(),
3469            )
3470            .await
3471            .unwrap();
3472        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3473    }
3474
3475    /// Why: issue #99 — the HTTP surface for inter-project messaging is what
3476    /// `trusty-memory send-message` and `trusty-memory inbox-check` both
3477    /// drive. We pin the round-trip (send → list-unread → mark-read →
3478    /// list-empty) so a future refactor cannot accidentally break either
3479    /// CLI without a failing test.
3480    /// What: pre-creates the recipient palace, POSTs a message, asserts
3481    /// `unread_only=true` returns exactly one entry with the right
3482    /// envelope fields, POSTs to mark_read, asserts the unread inbox is
3483    /// now empty, and confirms the audit view (`unread_only=false`) still
3484    /// surfaces the read message.
3485    /// Test: this test itself.
3486    #[tokio::test]
3487    async fn messages_endpoint_round_trip() {
3488        let state = test_state();
3489        let palace = trusty_common::memory_core::Palace {
3490            id: PalaceId::new("msg-test"),
3491            name: "msg-test".to_string(),
3492            description: None,
3493            created_at: chrono::Utc::now(),
3494            data_dir: state.data_root.join("msg-test"),
3495        };
3496        state
3497            .registry
3498            .create_palace(&state.data_root, palace)
3499            .expect("create_palace");
3500        let app = router().with_state(state);
3501
3502        // POST /api/v1/messages — send.
3503        let resp = app
3504            .clone()
3505            .oneshot(
3506                Request::builder()
3507                    .method("POST")
3508                    .uri("/api/v1/messages")
3509                    .header("content-type", "application/json")
3510                    .body(Body::from(
3511                        json!({
3512                            "to_palace":   "msg-test",
3513                            "from_palace": "sender-palace",
3514                            "purpose":     "task",
3515                            "content":     "please refresh schema"
3516                        })
3517                        .to_string(),
3518                    ))
3519                    .unwrap(),
3520            )
3521            .await
3522            .unwrap();
3523        assert_eq!(resp.status(), StatusCode::OK);
3524        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3525        let send_resp: Value = serde_json::from_slice(&bytes).unwrap();
3526        assert_eq!(send_resp["status"], "sent");
3527        let drawer_id = send_resp["drawer_id"]
3528            .as_str()
3529            .expect("drawer_id")
3530            .to_string();
3531
3532        // GET unread inbox.
3533        let resp = app
3534            .clone()
3535            .oneshot(
3536                Request::builder()
3537                    .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3538                    .body(Body::empty())
3539                    .unwrap(),
3540            )
3541            .await
3542            .unwrap();
3543        assert_eq!(resp.status(), StatusCode::OK);
3544        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3545        let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3546        assert_eq!(list.len(), 1);
3547        assert_eq!(list[0]["id"], drawer_id);
3548        assert_eq!(list[0]["from_palace"], "sender-palace");
3549        assert_eq!(list[0]["to_palace"], "msg-test");
3550        assert_eq!(list[0]["purpose"], "task");
3551        assert_eq!(list[0]["content"], "please refresh schema");
3552        assert_eq!(list[0]["read"], false);
3553        assert!(list[0]["formatted"]
3554            .as_str()
3555            .unwrap()
3556            .contains("sender-palace"));
3557
3558        // POST mark_read.
3559        let resp = app
3560            .clone()
3561            .oneshot(
3562                Request::builder()
3563                    .method("POST")
3564                    .uri("/api/v1/messages/mark_read")
3565                    .header("content-type", "application/json")
3566                    .body(Body::from(
3567                        json!({"palace": "msg-test", "drawer_id": drawer_id}).to_string(),
3568                    ))
3569                    .unwrap(),
3570            )
3571            .await
3572            .unwrap();
3573        assert_eq!(resp.status(), StatusCode::OK);
3574        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
3575        let mark: Value = serde_json::from_slice(&bytes).unwrap();
3576        assert_eq!(mark["flipped"], true);
3577
3578        // GET unread again — empty.
3579        let resp = app
3580            .clone()
3581            .oneshot(
3582                Request::builder()
3583                    .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3584                    .body(Body::empty())
3585                    .unwrap(),
3586            )
3587            .await
3588            .unwrap();
3589        assert_eq!(resp.status(), StatusCode::OK);
3590        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3591        let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3592        assert!(list.is_empty(), "inbox cleared after mark_read");
3593
3594        // GET history (unread_only=false) — still has the message, now read.
3595        let resp = app
3596            .oneshot(
3597                Request::builder()
3598                    .uri("/api/v1/messages?palace=msg-test&unread_only=false")
3599                    .body(Body::empty())
3600                    .unwrap(),
3601            )
3602            .await
3603            .unwrap();
3604        assert_eq!(resp.status(), StatusCode::OK);
3605        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3606        let history: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3607        assert_eq!(history.len(), 1);
3608        assert_eq!(history[0]["read"], true);
3609    }
3610
3611    /// Why: The chat assistant's tool surface is part of the public API — any
3612    /// drift in tool names or required-argument lists is a breaking change for
3613    /// the UI and any external automation. Pin the shape here so a refactor
3614    /// has to acknowledge it.
3615    /// What: Snapshots the names + every tool's `required` array.
3616    /// Test: This test itself.
3617    #[test]
3618    fn all_tools_returns_expected_set() {
3619        let tools = crate::chat::all_tools();
3620        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3621        assert_eq!(
3622            names,
3623            vec![
3624                "list_palaces",
3625                "get_palace",
3626                "recall_memories",
3627                "list_drawers",
3628                "kg_query",
3629                "get_config",
3630                "get_status",
3631                "get_dream_status",
3632                "get_palace_dream_status",
3633                "create_memory",
3634                "kg_assert",
3635                "memory_recall_all",
3636            ]
3637        );
3638        // Every tool's `parameters` must be a JSON Schema object with a
3639        // `required` array (possibly empty).
3640        for t in &tools {
3641            assert_eq!(
3642                t.parameters["type"], "object",
3643                "tool {} schema type",
3644                t.name
3645            );
3646            assert!(
3647                t.parameters["required"].is_array(),
3648                "tool {} required not array",
3649                t.name
3650            );
3651        }
3652    }
3653
3654    /// Why: `execute_tool` is the bridge between the model's tool_call
3655    /// arguments and the live Rust core. We exercise the happy path
3656    /// (`list_palaces` on an empty registry returns `[]`) and the unknown-
3657    /// tool path (returns `{"error": "..."}`) to lock down both branches.
3658    /// What: Calls execute_tool against a fresh `AppState`.
3659    /// Test: This test itself.
3660    #[tokio::test]
3661    async fn execute_tool_dispatches_known_tools() {
3662        let state = test_state();
3663        let result = crate::chat::execute_tool("list_palaces", "{}", &state).await;
3664        assert!(
3665            result.is_array(),
3666            "list_palaces should be array, got {result}"
3667        );
3668        assert_eq!(result.as_array().unwrap().len(), 0);
3669
3670        let unknown = crate::chat::execute_tool("not_a_tool", "{}", &state).await;
3671        assert!(
3672            unknown["error"]
3673                .as_str()
3674                .unwrap_or("")
3675                .contains("unknown tool"),
3676            "expected unknown-tool error, got {unknown}"
3677        );
3678
3679        let missing = crate::chat::execute_tool("get_palace", "{}", &state).await;
3680        assert!(
3681            missing["error"]
3682                .as_str()
3683                .unwrap_or("")
3684                .contains("palace_id"),
3685            "expected missing-arg error, got {missing}"
3686        );
3687    }
3688
3689    /// Why: The SSE event bus is the dashboard's live-update transport;
3690    /// regressing it would silently break the UI. Subscribing before the
3691    /// emit guarantees the broadcast channel has a receiver when the
3692    /// handler fires, so we can deterministically observe the event.
3693    /// What: Subscribes to `state.events`, calls the `create_palace`
3694    /// handler through the router, then asserts a `PalaceCreated` event
3695    /// (and a follow-up status event from drawer mutation) flow through.
3696    /// Test: `cargo test -p trusty-memory-mcp sse_broadcast_emits_palace_created`.
3697    #[tokio::test]
3698    async fn sse_broadcast_emits_palace_created() {
3699        let state = test_state();
3700        let mut rx = state.events.subscribe();
3701        let app = router().with_state(state.clone());
3702        let body = json!({"name": "sse-test"}).to_string();
3703        let resp = app
3704            .oneshot(
3705                Request::builder()
3706                    .method("POST")
3707                    .uri("/api/v1/palaces")
3708                    .header("content-type", "application/json")
3709                    .body(Body::from(body))
3710                    .unwrap(),
3711            )
3712            .await
3713            .unwrap();
3714        assert_eq!(resp.status(), StatusCode::OK);
3715        // The handler should have emitted PalaceCreated before returning.
3716        let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3717            .await
3718            .expect("event received within timeout")
3719            .expect("event channel still open");
3720        match event {
3721            DaemonEvent::PalaceCreated { id, name, source } => {
3722                assert_eq!(id, "sse-test");
3723                assert_eq!(name, "sse-test");
3724                assert_eq!(source, ActivitySource::Http);
3725            }
3726            other => panic!("expected PalaceCreated, got {other:?}"),
3727        }
3728    }
3729
3730    /// Why: Confirm the `/sse` endpoint speaks `text/event-stream` and emits
3731    /// the initial `connected` frame so dashboard clients can rely on a
3732    /// known greeting.
3733    /// What: Issues a GET against `/sse`, reads the response body chunk,
3734    /// asserts the content-type header and the first SSE frame shape.
3735    /// Test: `cargo test -p trusty-memory-mcp sse_endpoint_emits_connected_frame`.
3736    #[tokio::test]
3737    async fn sse_endpoint_emits_connected_frame() {
3738        use axum::routing::get;
3739        let state = test_state();
3740        let app = router()
3741            .route("/sse", get(crate::sse_handler))
3742            .with_state(state);
3743        let resp = app
3744            .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3745            .await
3746            .unwrap();
3747        assert_eq!(resp.status(), StatusCode::OK);
3748        assert_eq!(
3749            resp.headers()
3750                .get(header::CONTENT_TYPE)
3751                .and_then(|v| v.to_str().ok()),
3752            Some("text/event-stream")
3753        );
3754        // Read just the first chunk (the connected frame) — the stream stays
3755        // open otherwise, so we use a small read budget plus timeout.
3756        let body = resp.into_body();
3757        let bytes =
3758            tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3759                .await
3760                .ok()
3761                .and_then(|r| r.ok())
3762                .unwrap_or_default();
3763        let text = String::from_utf8_lossy(&bytes);
3764        assert!(
3765            text.contains("\"type\":\"connected\""),
3766            "expected connected frame, got: {text}"
3767        );
3768    }
3769
3770    /// Why: `/api/v1/dream/status` must sum per-palace `dream_stats.json`
3771    /// counters and surface the most recent `last_run_at`. A regression that
3772    /// returned only the first palace's stats would silently break the
3773    /// "global dream activity" dashboard panel.
3774    /// What: Pre-seeds two palace dirs under the AppState root, writes a
3775    /// distinct `PersistedDreamStats` JSON file into each, hits the endpoint,
3776    /// and asserts the integer fields are summed and `last_run_at` equals the
3777    /// newer of the two timestamps.
3778    /// Test: This test itself.
3779    #[tokio::test]
3780    async fn dream_status_aggregates_across_palaces() {
3781        use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3782
3783        let state = test_state();
3784        // Two palace directories — each must contain a `palace.json` so
3785        // `PalaceRegistry::list_palaces` sees them, plus a `dream_stats.json`
3786        // with distinct counter values.
3787        for (id, stats, ts) in [
3788            (
3789                "palace-a",
3790                DreamStats {
3791                    merged: 1,
3792                    pruned: 2,
3793                    compacted: 3,
3794                    closets_updated: 4,
3795                    duration_ms: 100,
3796                    ..DreamStats::default()
3797                },
3798                chrono::Utc::now() - chrono::Duration::seconds(60),
3799            ),
3800            (
3801                "palace-b",
3802                DreamStats {
3803                    merged: 10,
3804                    pruned: 20,
3805                    compacted: 30,
3806                    closets_updated: 40,
3807                    duration_ms: 200,
3808                    ..DreamStats::default()
3809                },
3810                chrono::Utc::now(),
3811            ),
3812        ] {
3813            let palace = trusty_common::memory_core::Palace {
3814                id: PalaceId::new(id),
3815                name: id.to_string(),
3816                description: None,
3817                created_at: chrono::Utc::now(),
3818                data_dir: state.data_root.join(id),
3819            };
3820            state
3821                .registry
3822                .create_palace(&state.data_root, palace)
3823                .expect("create palace");
3824            let persisted = PersistedDreamStats {
3825                last_run_at: ts,
3826                stats,
3827            };
3828            persisted
3829                .save(&state.data_root.join(id))
3830                .expect("save dream stats");
3831        }
3832
3833        let later = chrono::Utc::now();
3834        let app = router().with_state(state);
3835        let resp = app
3836            .oneshot(
3837                Request::builder()
3838                    .uri("/api/v1/dream/status")
3839                    .body(Body::empty())
3840                    .unwrap(),
3841            )
3842            .await
3843            .unwrap();
3844        assert_eq!(resp.status(), StatusCode::OK);
3845        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3846        let v: Value = serde_json::from_slice(&bytes).unwrap();
3847
3848        // Aggregated counters.
3849        assert_eq!(v["merged"], 11);
3850        assert_eq!(v["pruned"], 22);
3851        assert_eq!(v["compacted"], 33);
3852        assert_eq!(v["closets_updated"], 44);
3853        assert_eq!(v["duration_ms"], 300);
3854
3855        // `last_run_at` is the more-recent of the two timestamps.
3856        let last = v["last_run_at"].as_str().expect("last_run_at is string");
3857        let parsed: chrono::DateTime<chrono::Utc> = last
3858            .parse()
3859            .expect("last_run_at parses as RFC3339 timestamp");
3860        assert!(
3861            parsed <= later,
3862            "last_run_at ({parsed}) should not exceed wall clock ({later})"
3863        );
3864        // Must have picked palace-b's newer stamp, not palace-a's older one.
3865        let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3866        assert!(
3867            parsed >= cutoff,
3868            "expected the newer (palace-b) timestamp; got {parsed}"
3869        );
3870    }
3871
3872    /// Why: `POST /api/v1/dream/run` triggers a dream cycle across every
3873    /// palace and must return the aggregated stats. Even when no palace
3874    /// has work to do (empty registry) the endpoint must round-trip 200
3875    /// with the well-formed payload shape so the dashboard's "Run now"
3876    /// button never fails the UI.
3877    /// What: Pre-creates one palace via the registry, posts to the endpoint,
3878    /// and asserts the response is 200 with all expected fields present.
3879    /// Deeper assertions (specific merged/pruned counts) are skipped here
3880    /// because running a full dream cycle requires the ONNX embedder load
3881    /// path and we want this test to stay fast and embedder-free.
3882    /// Test: This test itself.
3883    #[tokio::test]
3884    async fn dream_run_aggregates_stats() {
3885        let state = test_state();
3886        let palace = trusty_common::memory_core::Palace {
3887            id: PalaceId::new("dream-run-test"),
3888            name: "dream-run-test".to_string(),
3889            description: None,
3890            created_at: chrono::Utc::now(),
3891            data_dir: state.data_root.join("dream-run-test"),
3892        };
3893        state
3894            .registry
3895            .create_palace(&state.data_root, palace)
3896            .expect("create palace");
3897
3898        let app = router().with_state(state);
3899        let resp = app
3900            .oneshot(
3901                Request::builder()
3902                    .method("POST")
3903                    .uri("/api/v1/dream/run")
3904                    .body(Body::empty())
3905                    .unwrap(),
3906            )
3907            .await
3908            .unwrap();
3909        assert_eq!(resp.status(), StatusCode::OK);
3910        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3911        let v: Value = serde_json::from_slice(&bytes).unwrap();
3912
3913        // Shape: every aggregated counter must be present (even if zero) and
3914        // `last_run_at` is set by the handler to "now".
3915        for key in [
3916            "merged",
3917            "pruned",
3918            "compacted",
3919            "closets_updated",
3920            "duration_ms",
3921        ] {
3922            assert!(
3923                v.get(key).is_some(),
3924                "missing key {key} in dream_run payload: {v}"
3925            );
3926            assert!(
3927                v[key].is_u64() || v[key].is_i64(),
3928                "{key} should be integer, got {}",
3929                v[key]
3930            );
3931        }
3932        assert!(
3933            v["last_run_at"].is_string(),
3934            "last_run_at must be set by dream_run; got {v}"
3935        );
3936    }
3937
3938    /// Why: Issue #53 — when the dream cycle has not yet run for a palace,
3939    /// `/api/v1/kg/gaps` must return an empty array (200 OK), not 404 or
3940    /// 500. The cache miss is a meaningful, non-error state.
3941    /// What: Creates a palace, queries `/api/v1/kg/gaps?palace=...`, asserts
3942    /// the response is `200` with body `[]`.
3943    /// Test: this test itself.
3944    #[tokio::test]
3945    async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3946        let state = test_state();
3947        let palace = trusty_common::memory_core::Palace {
3948            id: PalaceId::new("gaps-empty"),
3949            name: "gaps-empty".to_string(),
3950            description: None,
3951            created_at: chrono::Utc::now(),
3952            data_dir: state.data_root.join("gaps-empty"),
3953        };
3954        state
3955            .registry
3956            .create_palace(&state.data_root, palace)
3957            .expect("create palace");
3958
3959        let app = router().with_state(state);
3960        let resp = app
3961            .oneshot(
3962                Request::builder()
3963                    .uri("/api/v1/kg/gaps?palace=gaps-empty")
3964                    .body(Body::empty())
3965                    .unwrap(),
3966            )
3967            .await
3968            .unwrap();
3969        assert_eq!(resp.status(), StatusCode::OK);
3970        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3971        let v: Value = serde_json::from_slice(&bytes).unwrap();
3972        assert_eq!(v.as_array().expect("array").len(), 0);
3973    }
3974
3975    /// Why: Issue #53 — when the cache *has* been populated (by the dream
3976    /// cycle in production, or by direct seeding here), the endpoint must
3977    /// return each gap with the four wire fields.
3978    /// What: Seeds the registry cache via `set_gaps` directly, then GETs
3979    /// `/api/v1/kg/gaps?palace=...` and asserts the JSON shape.
3980    /// Test: this test itself.
3981    #[tokio::test]
3982    async fn kg_gaps_endpoint_returns_cached_gaps() {
3983        use trusty_common::memory_core::community::KnowledgeGap;
3984
3985        let state = test_state();
3986        let palace = trusty_common::memory_core::Palace {
3987            id: PalaceId::new("gaps-seed"),
3988            name: "gaps-seed".to_string(),
3989            description: None,
3990            created_at: chrono::Utc::now(),
3991            data_dir: state.data_root.join("gaps-seed"),
3992        };
3993        state
3994            .registry
3995            .create_palace(&state.data_root, palace)
3996            .expect("create palace");
3997
3998        state.registry.set_gaps(
3999            PalaceId::new("gaps-seed"),
4000            vec![KnowledgeGap {
4001                entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
4002                internal_density: 0.15,
4003                external_bridges: 2,
4004                suggested_exploration: "Explore connections between foo and related concepts"
4005                    .to_string(),
4006            }],
4007        );
4008
4009        let app = router().with_state(state);
4010        let resp = app
4011            .oneshot(
4012                Request::builder()
4013                    .uri("/api/v1/kg/gaps?palace=gaps-seed")
4014                    .body(Body::empty())
4015                    .unwrap(),
4016            )
4017            .await
4018            .unwrap();
4019        assert_eq!(resp.status(), StatusCode::OK);
4020        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4021        let v: Value = serde_json::from_slice(&bytes).unwrap();
4022        let arr = v.as_array().expect("array");
4023        assert_eq!(arr.len(), 1);
4024        assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
4025        assert_eq!(arr[0]["external_bridges"], 2);
4026        assert!(arr[0]["suggested_exploration"]
4027            .as_str()
4028            .unwrap()
4029            .contains("foo"));
4030    }
4031
4032    /// Why: The KG Explorer UI calls `/api/v1/palaces/{id}/kg/subjects` to
4033    /// populate the left panel; the endpoint must return distinct active
4034    /// subjects as a JSON string array.
4035    /// What: Creates a palace, asserts two triples via the existing kg endpoint,
4036    /// then GETs the subjects route and asserts the shape.
4037    /// Test: this test itself.
4038    #[tokio::test]
4039    async fn kg_list_subjects_returns_distinct() {
4040        let state = test_state();
4041        let app = router().with_state(state.clone());
4042
4043        // Create palace.
4044        let resp = app
4045            .clone()
4046            .oneshot(
4047                Request::builder()
4048                    .method("POST")
4049                    .uri("/api/v1/palaces")
4050                    .header("content-type", "application/json")
4051                    .body(Body::from(json!({"name": "kg-list"}).to_string()))
4052                    .unwrap(),
4053            )
4054            .await
4055            .unwrap();
4056        assert_eq!(resp.status(), StatusCode::OK);
4057
4058        // Assert two triples on distinct subjects.
4059        for subj in ["alpha", "beta"] {
4060            let body = json!({
4061                "subject": subj,
4062                "predicate": "is",
4063                "object": "thing",
4064            })
4065            .to_string();
4066            let r = app
4067                .clone()
4068                .oneshot(
4069                    Request::builder()
4070                        .method("POST")
4071                        .uri("/api/v1/palaces/kg-list/kg")
4072                        .header("content-type", "application/json")
4073                        .body(Body::from(body))
4074                        .unwrap(),
4075                )
4076                .await
4077                .unwrap();
4078            assert_eq!(r.status(), StatusCode::NO_CONTENT);
4079        }
4080
4081        let resp = app
4082            .oneshot(
4083                Request::builder()
4084                    .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
4085                    .body(Body::empty())
4086                    .unwrap(),
4087            )
4088            .await
4089            .unwrap();
4090        assert_eq!(resp.status(), StatusCode::OK);
4091        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4092        let v: Value = serde_json::from_slice(&bytes).unwrap();
4093        let arr = v.as_array().expect("subjects must be array");
4094        let subjects: Vec<String> = arr
4095            .iter()
4096            .filter_map(|x| x.as_str().map(String::from))
4097            .collect();
4098        assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
4099    }
4100
4101    /// Why: KG Explorer's "All" mode pages through every active triple via
4102    /// `/api/v1/palaces/{id}/kg/all`; the endpoint must return a JSON array of
4103    /// `Triple` rows ordered by `valid_from` DESC.
4104    /// What: Creates a palace, asserts a triple, then GETs the all route and
4105    /// asserts the response is an array with the expected shape.
4106    /// Test: this test itself.
4107    #[tokio::test]
4108    async fn kg_list_all_returns_paginated_triples() {
4109        let state = test_state();
4110        let app = router().with_state(state.clone());
4111
4112        let resp = app
4113            .clone()
4114            .oneshot(
4115                Request::builder()
4116                    .method("POST")
4117                    .uri("/api/v1/palaces")
4118                    .header("content-type", "application/json")
4119                    .body(Body::from(json!({"name": "kg-all"}).to_string()))
4120                    .unwrap(),
4121            )
4122            .await
4123            .unwrap();
4124        assert_eq!(resp.status(), StatusCode::OK);
4125
4126        let body = json!({
4127            "subject": "alpha",
4128            "predicate": "is",
4129            "object": "thing",
4130        })
4131        .to_string();
4132        let r = app
4133            .clone()
4134            .oneshot(
4135                Request::builder()
4136                    .method("POST")
4137                    .uri("/api/v1/palaces/kg-all/kg")
4138                    .header("content-type", "application/json")
4139                    .body(Body::from(body))
4140                    .unwrap(),
4141            )
4142            .await
4143            .unwrap();
4144        assert_eq!(r.status(), StatusCode::NO_CONTENT);
4145
4146        let resp = app
4147            .oneshot(
4148                Request::builder()
4149                    .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
4150                    .body(Body::empty())
4151                    .unwrap(),
4152            )
4153            .await
4154            .unwrap();
4155        assert_eq!(resp.status(), StatusCode::OK);
4156        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4157        let v: Value = serde_json::from_slice(&bytes).unwrap();
4158        let arr = v.as_array().expect("triples must be array");
4159        assert_eq!(arr.len(), 1);
4160        assert_eq!(arr[0]["subject"], "alpha");
4161        assert_eq!(arr[0]["predicate"], "is");
4162        assert_eq!(arr[0]["object"], "thing");
4163    }
4164
4165    /// Why (issue #97): The visual graph view fetches the entire active
4166    /// triple set in one call so d3-force can lay it out without paging.
4167    /// The endpoint must return the triple list plus the node/edge/
4168    /// community counts that drive the legend.
4169    /// What: Creates a palace, asserts a single triple, and confirms `GET
4170    /// /api/v1/palaces/{id}/kg/graph` returns `{ triples, node_count,
4171    /// edge_count, community_count }` with the right shape.
4172    /// Test: This test.
4173    #[tokio::test]
4174    async fn kg_graph_returns_active_triples() {
4175        let state = test_state();
4176        let app = router().with_state(state.clone());
4177
4178        let resp = app
4179            .clone()
4180            .oneshot(
4181                Request::builder()
4182                    .method("POST")
4183                    .uri("/api/v1/palaces")
4184                    .header("content-type", "application/json")
4185                    .body(Body::from(json!({"name": "kg-graph"}).to_string()))
4186                    .unwrap(),
4187            )
4188            .await
4189            .unwrap();
4190        assert_eq!(resp.status(), StatusCode::OK);
4191
4192        let body = json!({
4193            "subject": "alpha",
4194            "predicate": "is",
4195            "object": "thing",
4196        })
4197        .to_string();
4198        let r = app
4199            .clone()
4200            .oneshot(
4201                Request::builder()
4202                    .method("POST")
4203                    .uri("/api/v1/palaces/kg-graph/kg")
4204                    .header("content-type", "application/json")
4205                    .body(Body::from(body))
4206                    .unwrap(),
4207            )
4208            .await
4209            .unwrap();
4210        assert_eq!(r.status(), StatusCode::NO_CONTENT);
4211
4212        let resp = app
4213            .oneshot(
4214                Request::builder()
4215                    .uri("/api/v1/palaces/kg-graph/kg/graph")
4216                    .body(Body::empty())
4217                    .unwrap(),
4218            )
4219            .await
4220            .unwrap();
4221        assert_eq!(resp.status(), StatusCode::OK);
4222        let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
4223        let v: Value = serde_json::from_slice(&bytes).unwrap();
4224        let triples = v["triples"].as_array().expect("triples array");
4225        assert!(triples
4226            .iter()
4227            .any(|t| t["subject"] == "alpha" && t["predicate"] == "is" && t["object"] == "thing"));
4228        assert!(v["node_count"].as_u64().is_some());
4229        assert!(v["edge_count"].as_u64().is_some());
4230        assert!(v["community_count"].as_u64().is_some());
4231    }
4232
4233    /// Why (issue #97): The visual graph view's stated perf budget is
4234    /// "<1s for palaces with <500 triples". Seed 500 triples, time one
4235    /// `/kg/graph` round-trip, and assert the result stays well under that
4236    /// budget. The assertion uses a generous 10x ceiling so flaky CI
4237    /// hardware doesn't false-positive while still catching catastrophic
4238    /// regressions.
4239    /// What: Creates a palace, asserts 500 triples directly through the
4240    /// `KnowledgeGraph` handle (skipping the HTTP overhead of 500 separate
4241    /// `POST /kg` calls), then runs one `GET /kg/graph` and prints the
4242    /// elapsed time to stderr.
4243    /// Test: This test.
4244    #[tokio::test]
4245    async fn kg_graph_meets_perf_budget_for_500_triples() {
4246        let state = test_state();
4247        let app = router().with_state(state.clone());
4248
4249        let resp = app
4250            .clone()
4251            .oneshot(
4252                Request::builder()
4253                    .method("POST")
4254                    .uri("/api/v1/palaces")
4255                    .header("content-type", "application/json")
4256                    .body(Body::from(json!({"name": "kg-perf"}).to_string()))
4257                    .unwrap(),
4258            )
4259            .await
4260            .unwrap();
4261        assert_eq!(resp.status(), StatusCode::OK);
4262
4263        let pid = trusty_common::memory_core::palace::PalaceId::new("kg-perf");
4264        let handle = state
4265            .registry
4266            .open_palace(&state.data_root, &pid)
4267            .expect("open palace");
4268        let now = chrono::Utc::now();
4269        for s in 0..10 {
4270            for o in 0..50 {
4271                handle
4272                    .kg
4273                    .assert(Triple {
4274                        subject: format!("s{s}"),
4275                        predicate: format!("p{o}"),
4276                        object: format!("o{o}"),
4277                        valid_from: now,
4278                        valid_to: None,
4279                        confidence: 1.0,
4280                        provenance: Some("perf-test".to_string()),
4281                    })
4282                    .await
4283                    .expect("kg.assert");
4284            }
4285        }
4286
4287        let started = std::time::Instant::now();
4288        let resp = app
4289            .oneshot(
4290                Request::builder()
4291                    .uri("/api/v1/palaces/kg-perf/kg/graph")
4292                    .body(Body::empty())
4293                    .unwrap(),
4294            )
4295            .await
4296            .unwrap();
4297        let elapsed = started.elapsed();
4298        assert_eq!(resp.status(), StatusCode::OK);
4299        let bytes = to_bytes(resp.into_body(), 1_000_000).await.unwrap();
4300        let v: Value = serde_json::from_slice(&bytes).unwrap();
4301        let n = v["triples"].as_array().map(|a| a.len()).unwrap_or(0);
4302        assert_eq!(n, 500, "expected 500 triples in payload");
4303        assert!(
4304            elapsed.as_secs_f64() < 10.0,
4305            "graph endpoint should serve 500 triples in well under 10s; took {elapsed:?}"
4306        );
4307        eprintln!(
4308            "[perf] kg_graph endpoint served 500 triples in {:.3}ms",
4309            elapsed.as_secs_f64() * 1000.0
4310        );
4311    }
4312
4313    /// Why (issue #42): `GET /api/v1/kg/prompt-context` must serve the
4314    /// formatted Markdown block from the in-memory cache (or a placeholder
4315    /// when empty). Mirrors the MCP `get_prompt_context` tool but over HTTP.
4316    #[tokio::test]
4317    async fn prompt_context_endpoint_returns_formatted_block() {
4318        let state = test_state();
4319
4320        // Empty cache returns the placeholder text.
4321        let app = router().with_state(state.clone());
4322        let resp = app
4323            .oneshot(
4324                Request::builder()
4325                    .uri("/api/v1/kg/prompt-context")
4326                    .body(Body::empty())
4327                    .unwrap(),
4328            )
4329            .await
4330            .unwrap();
4331        assert_eq!(resp.status(), StatusCode::OK);
4332        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4333        let text = String::from_utf8(bytes.to_vec()).unwrap();
4334        assert_eq!(text, "No prompt facts stored yet.");
4335
4336        // Populate the cache and re-fetch.
4337        {
4338            let mut guard = state.prompt_context_cache.write().await;
4339            let triples = vec![(
4340                "tga".to_string(),
4341                "is_alias_for".to_string(),
4342                "trusty-git-analytics".to_string(),
4343            )];
4344            let formatted = crate::prompt_facts::build_prompt_context(&triples);
4345            *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
4346        }
4347        let app = router().with_state(state);
4348        let resp = app
4349            .oneshot(
4350                Request::builder()
4351                    .uri("/api/v1/kg/prompt-context")
4352                    .body(Body::empty())
4353                    .unwrap(),
4354            )
4355            .await
4356            .unwrap();
4357        assert_eq!(resp.status(), StatusCode::OK);
4358        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4359        let text = String::from_utf8(bytes.to_vec()).unwrap();
4360        assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
4361    }
4362
4363    /// Why (issue #42): `POST /api/v1/kg/aliases` must assert the alias as
4364    /// an `is_alias_for` triple AND refresh the prompt cache so subsequent
4365    /// reads see the new alias.
4366    #[tokio::test]
4367    async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
4368        let tmp = tempfile::tempdir().expect("tempdir");
4369        let root = tmp.path().to_path_buf();
4370        std::mem::forget(tmp);
4371        let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
4372        let palace = trusty_common::memory_core::Palace {
4373            id: PalaceId::new("aliases"),
4374            name: "aliases".to_string(),
4375            description: None,
4376            created_at: chrono::Utc::now(),
4377            data_dir: state.data_root.join("aliases"),
4378        };
4379        state
4380            .registry
4381            .create_palace(&state.data_root, palace)
4382            .expect("create palace");
4383
4384        let body = json!({"short": "tm", "full": "trusty-memory"});
4385        let app = router().with_state(state.clone());
4386        let resp = app
4387            .oneshot(
4388                Request::builder()
4389                    .method("POST")
4390                    .uri("/api/v1/kg/aliases")
4391                    .header("content-type", "application/json")
4392                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
4393                    .unwrap(),
4394            )
4395            .await
4396            .unwrap();
4397        assert_eq!(resp.status(), StatusCode::OK);
4398        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4399        let v: Value = serde_json::from_slice(&bytes).unwrap();
4400        assert_eq!(v["subject"], "tm");
4401        assert_eq!(v["object"], "trusty-memory");
4402
4403        // The prompt cache must reflect the new alias.
4404        let guard = state.prompt_context_cache.read().await;
4405        assert!(
4406            guard.formatted.contains("tm → trusty-memory"),
4407            "cache missing alias; got: {}",
4408            guard.formatted
4409        );
4410    }
4411
4412    /// Why (issue #42): `GET /api/v1/kg/prompt-facts` returns the structured
4413    /// JSON array of every hot-predicate triple across the registry (so a
4414    /// dashboard can render its own table).
4415    #[tokio::test]
4416    async fn list_prompt_facts_endpoint_returns_hot_triples() {
4417        let tmp = tempfile::tempdir().expect("tempdir");
4418        let root = tmp.path().to_path_buf();
4419        std::mem::forget(tmp);
4420        let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
4421        let palace = trusty_common::memory_core::Palace {
4422            id: PalaceId::new("listfacts"),
4423            name: "listfacts".to_string(),
4424            description: None,
4425            created_at: chrono::Utc::now(),
4426            data_dir: state.data_root.join("listfacts"),
4427        };
4428        let handle = state
4429            .registry
4430            .create_palace(&state.data_root, palace)
4431            .expect("create palace");
4432
4433        // Insert one hot triple and one non-hot triple; only the hot one
4434        // should surface.
4435        handle
4436            .kg
4437            .assert(Triple {
4438                subject: "ts".to_string(),
4439                predicate: "is_alias_for".to_string(),
4440                object: "trusty-search".to_string(),
4441                valid_from: chrono::Utc::now(),
4442                valid_to: None,
4443                confidence: 1.0,
4444                provenance: None,
4445            })
4446            .await
4447            .expect("assert alias");
4448        handle
4449            .kg
4450            .assert(Triple {
4451                subject: "alice".to_string(),
4452                predicate: "works_at".to_string(),
4453                object: "Acme".to_string(),
4454                valid_from: chrono::Utc::now(),
4455                valid_to: None,
4456                confidence: 1.0,
4457                provenance: None,
4458            })
4459            .await
4460            .expect("assert works_at");
4461
4462        let app = router().with_state(state);
4463        let resp = app
4464            .oneshot(
4465                Request::builder()
4466                    .uri("/api/v1/kg/prompt-facts")
4467                    .body(Body::empty())
4468                    .unwrap(),
4469            )
4470            .await
4471            .unwrap();
4472        assert_eq!(resp.status(), StatusCode::OK);
4473        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4474        let v: Value = serde_json::from_slice(&bytes).unwrap();
4475        let arr = v.as_array().expect("array");
4476        assert!(
4477            arr.iter().any(|r| r["subject"] == "ts"
4478                && r["predicate"] == "is_alias_for"
4479                && r["object"] == "trusty-search"),
4480            "missing ts alias; got {arr:?}"
4481        );
4482        // The non-hot `works_at` triple must not be present.
4483        assert!(
4484            !arr.iter().any(|r| r["predicate"] == "works_at"),
4485            "non-hot triple leaked into prompt facts: {arr:?}"
4486        );
4487    }
4488
4489    /// Why (issue #42): `DELETE /api/v1/kg/prompt-facts` must retract the
4490    /// interval and refresh the cache; the next list call must omit it.
4491    #[tokio::test]
4492    async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
4493        let tmp = tempfile::tempdir().expect("tempdir");
4494        let root = tmp.path().to_path_buf();
4495        std::mem::forget(tmp);
4496        let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
4497        let palace = trusty_common::memory_core::Palace {
4498            id: PalaceId::new("rmfacts"),
4499            name: "rmfacts".to_string(),
4500            description: None,
4501            created_at: chrono::Utc::now(),
4502            data_dir: state.data_root.join("rmfacts"),
4503        };
4504        let handle = state
4505            .registry
4506            .create_palace(&state.data_root, palace)
4507            .expect("create palace");
4508
4509        handle
4510            .kg
4511            .assert(Triple {
4512                subject: "ta".to_string(),
4513                predicate: "is_alias_for".to_string(),
4514                object: "trusty-analyze".to_string(),
4515                valid_from: chrono::Utc::now(),
4516                valid_to: None,
4517                confidence: 1.0,
4518                provenance: None,
4519            })
4520            .await
4521            .expect("assert alias");
4522        // Prime the cache so we can observe the removal effect.
4523        crate::prompt_facts::rebuild_prompt_cache(&state)
4524            .await
4525            .expect("rebuild prompt cache");
4526
4527        let app = router().with_state(state.clone());
4528        let resp = app
4529            .oneshot(
4530                Request::builder()
4531                    .method("DELETE")
4532                    .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
4533                    .body(Body::empty())
4534                    .unwrap(),
4535            )
4536            .await
4537            .unwrap();
4538        assert_eq!(resp.status(), StatusCode::OK);
4539        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4540        let v: Value = serde_json::from_slice(&bytes).unwrap();
4541        assert_eq!(v["removed"], true);
4542        assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
4543
4544        // Cache must no longer contain the alias.
4545        {
4546            let guard = state.prompt_context_cache.read().await;
4547            assert!(
4548                !guard.formatted.contains("ta → trusty-analyze"),
4549                "alias still in cache after delete: {}",
4550                guard.formatted
4551            );
4552        }
4553
4554        // Removing a non-existent fact returns removed=false.
4555        let app = router().with_state(state);
4556        let resp = app
4557            .oneshot(
4558                Request::builder()
4559                    .method("DELETE")
4560                    .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
4561                    .body(Body::empty())
4562                    .unwrap(),
4563            )
4564            .await
4565            .unwrap();
4566        assert_eq!(resp.status(), StatusCode::OK);
4567        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4568        let v: Value = serde_json::from_slice(&bytes).unwrap();
4569        assert_eq!(v["removed"], false);
4570    }
4571
4572    #[tokio::test]
4573    async fn serves_index_html_fallback() {
4574        let state = test_state();
4575        let app = router().with_state(state);
4576        let resp = app
4577            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4578            .await
4579            .unwrap();
4580        // Either OK with embedded HTML, or NOT_FOUND if assets not built.
4581        assert!(
4582            resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
4583            "got {}",
4584            resp.status()
4585        );
4586    }
4587
4588    /// Why (issue #96): `GET /api/v1/activity` must return the entries
4589    /// captured by the persistent log so the dashboard feed has history on
4590    /// page load. This drives the endpoint with a sequence of emits that
4591    /// model both HTTP- and MCP-origin writes, then asserts the response
4592    /// shape, ordering, total count, and that the source labels make it
4593    /// onto the wire.
4594    /// What: emits four `DaemonEvent`s with mixed sources, fetches
4595    /// `/api/v1/activity?limit=10`, and checks the structure of the
4596    /// returned JSON.
4597    /// Test: this test.
4598    #[tokio::test]
4599    async fn activity_endpoint_lists_recent_emits() {
4600        let state = test_state();
4601        // Three drawer_added (one MCP, two HTTP) and one palace_created.
4602        state.emit(DaemonEvent::PalaceCreated {
4603            id: "alpha".into(),
4604            name: "alpha".into(),
4605            source: ActivitySource::Http,
4606        });
4607        state.emit(DaemonEvent::DrawerAdded {
4608            palace_id: "alpha".into(),
4609            palace_name: "alpha".into(),
4610            drawer_count: 1,
4611            timestamp: chrono::Utc::now(),
4612            content_preview: "hello".into(),
4613            source: ActivitySource::Mcp,
4614        });
4615        state.emit(DaemonEvent::DrawerAdded {
4616            palace_id: "beta".into(),
4617            palace_name: "beta".into(),
4618            drawer_count: 1,
4619            timestamp: chrono::Utc::now(),
4620            content_preview: "hi there".into(),
4621            source: ActivitySource::Http,
4622        });
4623        state.emit(DaemonEvent::DrawerDeleted {
4624            palace_id: "alpha".into(),
4625            drawer_count: 0,
4626            source: ActivitySource::Http,
4627        });
4628        // Issue #232: emits now fire-and-forget the redb write on the
4629        // blocking pool; wait for the writes to settle before querying the
4630        // activity endpoint.
4631        state.flush_activity_writes().await;
4632
4633        let app = router().with_state(state);
4634        let resp = app
4635            .oneshot(
4636                Request::builder()
4637                    .uri("/api/v1/activity?limit=10")
4638                    .body(Body::empty())
4639                    .unwrap(),
4640            )
4641            .await
4642            .unwrap();
4643        assert_eq!(resp.status(), StatusCode::OK);
4644        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4645        let v: Value = serde_json::from_slice(&bytes).unwrap();
4646        assert_eq!(v["limit"], 10);
4647        assert_eq!(v["offset"], 0);
4648        assert_eq!(v["total"], 4);
4649        let entries = v["entries"].as_array().expect("entries array");
4650        assert_eq!(entries.len(), 4);
4651        // Newest-first: drawer_deleted is the last event we pushed.
4652        assert_eq!(entries[0]["event_type"], "drawer_deleted");
4653        assert_eq!(entries[3]["event_type"], "palace_created");
4654        // Sources made it onto the wire as lowercase strings.
4655        let sources: Vec<&str> = entries
4656            .iter()
4657            .filter_map(|e| e["source"].as_str())
4658            .collect();
4659        assert!(sources.contains(&"http"));
4660        assert!(sources.contains(&"mcp"));
4661        // Payload is structured JSON, not an escaped string.
4662        assert!(entries[0]["payload"].is_object());
4663    }
4664
4665    /// Why: the handler must enforce a sane upper bound on `limit` so a
4666    /// curl with `?limit=1000000` cannot force a huge scan + response.
4667    /// What: asks for `limit=10000`, asserts the response advertises the
4668    /// clamped value.
4669    /// Test: this test.
4670    #[tokio::test]
4671    async fn activity_endpoint_clamps_limit() {
4672        let state = test_state();
4673        let app = router().with_state(state);
4674        let resp = app
4675            .oneshot(
4676                Request::builder()
4677                    .uri("/api/v1/activity?limit=10000")
4678                    .body(Body::empty())
4679                    .unwrap(),
4680            )
4681            .await
4682            .unwrap();
4683        assert_eq!(resp.status(), StatusCode::OK);
4684        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4685        let v: Value = serde_json::from_slice(&bytes).unwrap();
4686        assert_eq!(v["limit"], ACTIVITY_MAX_LIMIT);
4687    }
4688
4689    /// Why: filters are how the dashboard scopes the feed to a single
4690    /// palace or to one origin (MCP vs HTTP). Confirm AND-semantics on
4691    /// `?palace=` and `?source=`.
4692    /// What: emits 3 events, queries with `?palace=alpha&source=mcp`, and
4693    /// asserts only the matching row is returned.
4694    /// Test: this test.
4695    #[tokio::test]
4696    async fn activity_endpoint_filters_by_source_and_palace() {
4697        let state = test_state();
4698        state.emit(DaemonEvent::DrawerAdded {
4699            palace_id: "alpha".into(),
4700            palace_name: "alpha".into(),
4701            drawer_count: 1,
4702            timestamp: chrono::Utc::now(),
4703            content_preview: "".into(),
4704            source: ActivitySource::Mcp,
4705        });
4706        state.emit(DaemonEvent::DrawerAdded {
4707            palace_id: "alpha".into(),
4708            palace_name: "alpha".into(),
4709            drawer_count: 2,
4710            timestamp: chrono::Utc::now(),
4711            content_preview: "".into(),
4712            source: ActivitySource::Http,
4713        });
4714        state.emit(DaemonEvent::DrawerAdded {
4715            palace_id: "beta".into(),
4716            palace_name: "beta".into(),
4717            drawer_count: 1,
4718            timestamp: chrono::Utc::now(),
4719            content_preview: "".into(),
4720            source: ActivitySource::Mcp,
4721        });
4722        // Issue #232: drain the spawn_blocking writes before querying.
4723        state.flush_activity_writes().await;
4724
4725        let app = router().with_state(state);
4726        let resp = app
4727            .oneshot(
4728                Request::builder()
4729                    .uri("/api/v1/activity?palace=alpha&source=mcp&limit=50")
4730                    .body(Body::empty())
4731                    .unwrap(),
4732            )
4733            .await
4734            .unwrap();
4735        assert_eq!(resp.status(), StatusCode::OK);
4736        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4737        let v: Value = serde_json::from_slice(&bytes).unwrap();
4738        let entries = v["entries"].as_array().unwrap();
4739        assert_eq!(entries.len(), 1, "filter should leave one row, got {v}");
4740        assert_eq!(entries[0]["palace_id"], "alpha");
4741        assert_eq!(entries[0]["source"], "mcp");
4742    }
4743
4744    /// Why: unknown source values must produce a 400 so the caller sees the
4745    /// typo instead of silently getting "no rows".
4746    #[tokio::test]
4747    async fn activity_endpoint_rejects_unknown_source() {
4748        let state = test_state();
4749        let app = router().with_state(state);
4750        let resp = app
4751            .oneshot(
4752                Request::builder()
4753                    .uri("/api/v1/activity?source=nope")
4754                    .body(Body::empty())
4755                    .unwrap(),
4756            )
4757            .await
4758            .unwrap();
4759        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4760    }
4761
4762    /// Why (issue #96): MCP-side `memory_remember` must now emit a
4763    /// `DrawerAdded` event with `source = Mcp`. Confirm by driving the MCP
4764    /// dispatcher directly and reading the broadcast channel.
4765    /// What: pre-creates a palace, calls `dispatch_tool("memory_remember",
4766    /// ...)`, subscribes to the events channel before the call, and
4767    /// asserts the next event tag is `drawer_added` with the MCP source.
4768    /// Test: this test.
4769    #[tokio::test]
4770    async fn mcp_memory_remember_emits_drawer_added_with_mcp_source() {
4771        use crate::tools::dispatch_tool;
4772        let state = test_state();
4773        let mut rx = state.events.subscribe();
4774        // Create palace via the MCP tool so the activity log captures both
4775        // the palace_created and drawer_added events.
4776        let _ = dispatch_tool(&state, "palace_create", json!({"name": "p1"}))
4777            .await
4778            .expect("palace_create");
4779        // Drain the palace_created event.
4780        let first = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4781            .await
4782            .expect("first event")
4783            .expect("channel open");
4784        assert!(
4785            matches!(first, DaemonEvent::PalaceCreated { ref source, .. } if *source == ActivitySource::Mcp)
4786        );
4787
4788        let _ = dispatch_tool(
4789            &state,
4790            "memory_remember",
4791            json!({
4792                "palace": "p1",
4793                "text": "the quick brown fox jumps over the lazy dog and more"
4794            }),
4795        )
4796        .await
4797        .expect("memory_remember");
4798
4799        // The next event from the channel should be DrawerAdded(Mcp).
4800        let next = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4801            .await
4802            .expect("drawer_added event")
4803            .expect("channel open");
4804        match next {
4805            DaemonEvent::DrawerAdded {
4806                source, palace_id, ..
4807            } => {
4808                assert_eq!(source, ActivitySource::Mcp);
4809                assert_eq!(palace_id, "p1");
4810            }
4811            other => panic!("expected DrawerAdded, got {other:?}"),
4812        }
4813
4814        // The activity log should now hold ≥ 2 entries (palace_created +
4815        // drawer_added). Also confirm the HTTP endpoint surfaces them with
4816        // `mcp` sources.
4817        // Issue #232: drain fire-and-forget activity-log writes first.
4818        state.flush_activity_writes().await;
4819        let app = router().with_state(state);
4820        let resp = app
4821            .oneshot(
4822                Request::builder()
4823                    .uri("/api/v1/activity?source=mcp&limit=10")
4824                    .body(Body::empty())
4825                    .unwrap(),
4826            )
4827            .await
4828            .unwrap();
4829        assert_eq!(resp.status(), StatusCode::OK);
4830        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4831        let v: Value = serde_json::from_slice(&bytes).unwrap();
4832        let entries = v["entries"].as_array().unwrap();
4833        let event_types: std::collections::HashSet<&str> = entries
4834            .iter()
4835            .filter_map(|e| e["event_type"].as_str())
4836            .collect();
4837        assert!(event_types.contains("drawer_added"));
4838        assert!(event_types.contains("palace_created"));
4839    }
4840
4841    // -----------------------------------------------------------------
4842    // Submission-logging tests (Part A: hook activity, Part B: drawer
4843    // attribution).
4844    // -----------------------------------------------------------------
4845
4846    /// Why (submission-logging Part A): every hook firing must produce an
4847    /// activity-feed entry tagged `source=hook` so a normal Claude Code
4848    /// session that only triggers hooks no longer leaves the TUI feed
4849    /// empty. The simplest direct check is to POST to the hook ingestion
4850    /// endpoint and confirm the new entry shows up in `GET /api/v1/activity`.
4851    /// What: posts a `HookEventPayload` to `/api/v1/activity/hook`, then
4852    /// queries `/api/v1/activity?source=hook&limit=1` and asserts a row
4853    /// exists with the matching event_type and source.
4854    /// Test: itself.
4855    #[tokio::test]
4856    async fn hook_fired_activity_emit_smoke() {
4857        let state = test_state();
4858        let app = router().with_state(state.clone());
4859
4860        let payload = serde_json::json!({
4861            "palace_id": "alpha",
4862            "palace_name": "alpha",
4863            "hook_type": "UserPromptSubmit",
4864            "injection_kind": "prompt-context",
4865            "injection_length": 256,
4866            "trigger_prompt_excerpt": "test prompt",
4867            "duration_ms": 12,
4868        });
4869        let resp = app
4870            .oneshot(
4871                Request::builder()
4872                    .method("POST")
4873                    .uri("/api/v1/activity/hook")
4874                    .header("content-type", "application/json")
4875                    .body(Body::from(payload.to_string()))
4876                    .unwrap(),
4877            )
4878            .await
4879            .unwrap();
4880        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
4881        // Issue #232: the hook handler emits via the fire-and-forget
4882        // `spawn_blocking` path; wait for the write to settle before
4883        // reading the activity history endpoint.
4884        state.flush_activity_writes().await;
4885
4886        // Read it back through the activity history endpoint.
4887        let app = router().with_state(state);
4888        let resp = app
4889            .oneshot(
4890                Request::builder()
4891                    .uri("/api/v1/activity?source=hook&limit=10")
4892                    .body(Body::empty())
4893                    .unwrap(),
4894            )
4895            .await
4896            .unwrap();
4897        assert_eq!(resp.status(), StatusCode::OK);
4898        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4899        let v: Value = serde_json::from_slice(&bytes).unwrap();
4900        let entries = v["entries"].as_array().expect("entries array");
4901        assert!(
4902            !entries.is_empty(),
4903            "expected at least one hook activity row, got {entries:?}"
4904        );
4905        let first = &entries[0];
4906        assert_eq!(first["source"], "hook");
4907        assert_eq!(first["event_type"], "hook_fired");
4908        assert_eq!(first["palace_id"], "alpha");
4909        let body = &first["payload"];
4910        assert_eq!(body["hook_type"], "UserPromptSubmit");
4911        assert_eq!(body["injection_kind"], "prompt-context");
4912    }
4913
4914    /// Why (submission-logging Part B): an HTTP drawer write with no
4915    /// client-identifying header must still produce a drawer carrying a
4916    /// `creator:client=unknown-http-client` tag so operators can recognise
4917    /// "writer didn't self-identify" as distinct from "writer is known".
4918    /// What: creates a palace via the registry, POSTs a drawer with no
4919    /// `X-Trusty-Client-Name` header, lists the palace drawers, asserts
4920    /// the new drawer carries the four creator tags with the default
4921    /// client name and `source=http`.
4922    /// Test: itself.
4923    #[tokio::test]
4924    async fn drawer_creator_attribution_http_default() {
4925        let tmp = tempfile::tempdir().expect("tempdir");
4926        let root = tmp.path().to_path_buf();
4927        std::mem::forget(tmp);
4928        let state = AppState::new(root);
4929        let palace = trusty_common::memory_core::Palace {
4930            id: PalaceId::new("cred-default"),
4931            name: "cred-default".to_string(),
4932            description: None,
4933            created_at: chrono::Utc::now(),
4934            data_dir: state.data_root.join("cred-default"),
4935        };
4936        state
4937            .registry
4938            .create_palace(&state.data_root, palace)
4939            .expect("create palace");
4940
4941        let app = router().with_state(state.clone());
4942        let body = serde_json::json!({
4943            "content": "hello world from anonymous client",
4944            "tags": ["user-tag"],
4945        });
4946        let resp = app
4947            .oneshot(
4948                Request::builder()
4949                    .method("POST")
4950                    .uri("/api/v1/palaces/cred-default/drawers")
4951                    .header("content-type", "application/json")
4952                    .body(Body::from(body.to_string()))
4953                    .unwrap(),
4954            )
4955            .await
4956            .unwrap();
4957        assert_eq!(resp.status(), StatusCode::OK);
4958
4959        // Inspect the persisted drawer's tags.
4960        let app = router().with_state(state);
4961        let resp = app
4962            .oneshot(
4963                Request::builder()
4964                    .uri("/api/v1/palaces/cred-default/drawers?limit=10")
4965                    .body(Body::empty())
4966                    .unwrap(),
4967            )
4968            .await
4969            .unwrap();
4970        assert_eq!(resp.status(), StatusCode::OK);
4971        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4972        let v: Value = serde_json::from_slice(&bytes).unwrap();
4973        let drawers = v.as_array().expect("drawers array");
4974        assert_eq!(drawers.len(), 1, "expected one drawer, got {drawers:?}");
4975        let tags: Vec<&str> = drawers[0]["tags"]
4976            .as_array()
4977            .expect("tags array")
4978            .iter()
4979            .filter_map(|t| t.as_str())
4980            .collect();
4981        assert!(
4982            tags.contains(&"user-tag"),
4983            "user-supplied tag must survive; got {tags:?}"
4984        );
4985        assert!(
4986            tags.contains(&"creator:client=unknown-http-client"),
4987            "expected default client tag; got {tags:?}"
4988        );
4989        assert!(
4990            tags.contains(&"creator:source=http"),
4991            "expected http source tag; got {tags:?}"
4992        );
4993        assert!(
4994            tags.iter().any(|t| t.starts_with("creator:version=")),
4995            "expected creator:version tag; got {tags:?}"
4996        );
4997    }
4998
4999    /// Why (submission-logging Part B): when an HTTP client *does* set
5000    /// `X-Trusty-Client-Name`, the drawer must carry that exact name in
5001    /// its `creator:client=` tag so operators can trace which client wrote
5002    /// which drawer.
5003    /// What: POST with `X-Trusty-Client-Name: qa-curl` and assert the
5004    /// rendered tag matches.
5005    /// Test: itself.
5006    #[tokio::test]
5007    async fn drawer_creator_attribution_http_header() {
5008        let tmp = tempfile::tempdir().expect("tempdir");
5009        let root = tmp.path().to_path_buf();
5010        std::mem::forget(tmp);
5011        let state = AppState::new(root);
5012        let palace = trusty_common::memory_core::Palace {
5013            id: PalaceId::new("cred-header"),
5014            name: "cred-header".to_string(),
5015            description: None,
5016            created_at: chrono::Utc::now(),
5017            data_dir: state.data_root.join("cred-header"),
5018        };
5019        state
5020            .registry
5021            .create_palace(&state.data_root, palace)
5022            .expect("create palace");
5023
5024        let app = router().with_state(state.clone());
5025        let body = serde_json::json!({
5026            "content": "this is enough content to pass the signal/noise filter applied by remember",
5027            "tags": [],
5028        });
5029        let resp = app
5030            .oneshot(
5031                Request::builder()
5032                    .method("POST")
5033                    .uri("/api/v1/palaces/cred-header/drawers")
5034                    .header("content-type", "application/json")
5035                    .header("x-trusty-client-name", "qa-curl")
5036                    .header("x-trusty-client-cwd", "/tmp/qa")
5037                    .body(Body::from(body.to_string()))
5038                    .unwrap(),
5039            )
5040            .await
5041            .unwrap();
5042        assert_eq!(resp.status(), StatusCode::OK);
5043
5044        let app = router().with_state(state);
5045        let resp = app
5046            .oneshot(
5047                Request::builder()
5048                    .uri("/api/v1/palaces/cred-header/drawers?limit=10")
5049                    .body(Body::empty())
5050                    .unwrap(),
5051            )
5052            .await
5053            .unwrap();
5054        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
5055        let v: Value = serde_json::from_slice(&bytes).unwrap();
5056        let tags: Vec<&str> = v[0]["tags"]
5057            .as_array()
5058            .expect("tags")
5059            .iter()
5060            .filter_map(|t| t.as_str())
5061            .collect();
5062        assert!(
5063            tags.contains(&"creator:client=qa-curl"),
5064            "expected custom client tag; got {tags:?}"
5065        );
5066        assert!(
5067            tags.contains(&"creator:cwd=/tmp/qa"),
5068            "expected cwd tag from header; got {tags:?}"
5069        );
5070    }
5071
5072    /// Why (submission-logging Part B): drawers written through the MCP
5073    /// tool surface (`memory_remember`) must carry
5074    /// `creator:client=trusty-memory-mcp` and `creator:source=mcp` so
5075    /// operators can tell MCP-origin drawers apart from HTTP / CLI writes.
5076    /// What: dispatches `memory_remember` directly against an in-process
5077    /// `AppState` (no HTTP), then lists the palace drawers and asserts
5078    /// the MCP attribution tags landed.
5079    /// Test: itself.
5080    #[tokio::test]
5081    async fn drawer_creator_attribution_mcp_default() {
5082        let tmp = tempfile::tempdir().expect("tempdir");
5083        let root = tmp.path().to_path_buf();
5084        std::mem::forget(tmp);
5085        let state = AppState::new(root);
5086        // Flip to Ready so the issue #911 warming preflight allows dispatch_tool
5087        // to proceed (the call below goes through the MCP handler path).
5088        state.set_ready();
5089        let palace = trusty_common::memory_core::Palace {
5090            id: PalaceId::new("cred-mcp"),
5091            name: "cred-mcp".to_string(),
5092            description: None,
5093            created_at: chrono::Utc::now(),
5094            data_dir: state.data_root.join("cred-mcp"),
5095        };
5096        state
5097            .registry
5098            .create_palace(&state.data_root, palace)
5099            .expect("create palace");
5100
5101        let _ = crate::tools::dispatch_tool(
5102            &state,
5103            "memory_remember",
5104            json!({
5105                "palace": "cred-mcp",
5106                "text": "remember a sentence with enough tokens to pass filters please",
5107                "room": "General",
5108                "tags": ["from-test"],
5109            }),
5110        )
5111        .await
5112        .expect("memory_remember dispatch");
5113
5114        let app = router().with_state(state);
5115        let resp = app
5116            .oneshot(
5117                Request::builder()
5118                    .uri("/api/v1/palaces/cred-mcp/drawers?limit=10")
5119                    .body(Body::empty())
5120                    .unwrap(),
5121            )
5122            .await
5123            .unwrap();
5124        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
5125        let v: Value = serde_json::from_slice(&bytes).unwrap();
5126        let drawers = v.as_array().expect("drawers array");
5127        assert!(!drawers.is_empty(), "expected at least one drawer");
5128        let tags: Vec<&str> = drawers[0]["tags"]
5129            .as_array()
5130            .expect("tags array")
5131            .iter()
5132            .filter_map(|t| t.as_str())
5133            .collect();
5134        assert!(
5135            tags.contains(&"creator:client=trusty-memory-mcp"),
5136            "expected MCP client tag; got {tags:?}"
5137        );
5138        assert!(
5139            tags.contains(&"creator:source=mcp"),
5140            "expected MCP source tag; got {tags:?}"
5141        );
5142    }
5143
5144    /// Why (submission-logging Part A, failure isolation): if the daemon
5145    /// is unreachable when the hook fires, the hook command MUST still
5146    /// return `Ok(())` so the user's prompt is not blocked. The activity
5147    /// emit failure is surfaced via a stderr warn-log only.
5148    /// What: pins a tempdir as the data dir (so `read_daemon_addr`
5149    /// returns `Ok(None)` — no http_addr file), runs `handle_prompt_context`,
5150    /// and asserts it returns `Ok(())`. Separately verifies the emit
5151    /// helper does not panic — covered by `post_hook_event_no_daemon_is_noop`
5152    /// in `hook_emit::tests`.
5153    /// Test: itself.
5154    #[tokio::test]
5155    async fn hook_emit_failure_isolated() {
5156        let _guard = crate::commands::env_test_lock().lock().await;
5157        let tmp = tempfile::tempdir().expect("tempdir");
5158        // SAFETY: test serialised via env_test_lock.
5159        unsafe {
5160            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
5161        }
5162        let res = crate::commands::prompt_context::handle_prompt_context().await;
5163        unsafe {
5164            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
5165        }
5166        assert!(
5167            res.is_ok(),
5168            "hook must complete even when daemon emit fails; got {res:?}"
5169        );
5170    }
5171
5172    /// Why: The base64url triple-ID round-trip is the core invariant for
5173    /// `DELETE /kg/triples/<id>` — if encode/decode aren't inverses, the
5174    /// handler will always 404 on valid IDs.
5175    /// What: Encodes a (subject, predicate) pair, decodes the result, and
5176    /// asserts exact equality with the originals. Also tests the null-byte
5177    /// separator and URL-safety.
5178    /// Test: This test.
5179    #[test]
5180    fn decode_triple_id_round_trips() {
5181        let cases = [
5182            ("drawer:some-uuid", "has_tag"),
5183            ("entity:alice", "works_at"),
5184            ("entity:project/foo", "depends_on"),
5185            // edge: empty predicate
5186            ("subject", ""),
5187            // edge: subject with slashes + predicate with colons
5188            ("path/to/node", "rel:type:sub"),
5189        ];
5190        for (subject, predicate) in cases {
5191            let encoded = encode_triple_id(subject, predicate);
5192            // Must be URL-safe: no +, /, or = characters.
5193            assert!(
5194                !encoded.contains('+') && !encoded.contains('/') && !encoded.contains('='),
5195                "encoded triple id {encoded:?} is not URL-safe"
5196            );
5197            let (s, p) = decode_triple_id(&encoded)
5198                .unwrap_or_else(|| panic!("decode_triple_id failed for {encoded:?}"));
5199            assert_eq!(s, subject, "subject mismatch for ({subject}, {predicate})");
5200            assert_eq!(
5201                p, predicate,
5202                "predicate mismatch for ({subject}, {predicate})"
5203            );
5204        }
5205    }
5206
5207    /// Why: `decode_triple_id` must return `None` on garbage input (not panic).
5208    /// What: Passes invalid base64 and base64 without a null separator; asserts None.
5209    /// Test: This test.
5210    #[test]
5211    fn decode_triple_id_returns_none_for_invalid_input() {
5212        assert!(decode_triple_id("not!!valid%%base64").is_none());
5213        // Valid base64url but no null separator → no split possible.
5214        use base64::Engine as _;
5215        let no_sep = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"no-separator");
5216        assert!(decode_triple_id(&no_sep).is_none());
5217    }
5218
5219    // -------------------------------------------------------------------------
5220    // Issue #465 — GET /api/v1/recall?palace= must honour the palace filter
5221    // -------------------------------------------------------------------------
5222
5223    /// Why (issue #465): `GET /api/v1/recall?palace=<id>&q=...` was silently
5224    /// ignoring the `palace=` parameter and always fanning out across all
5225    /// palaces, returning results from the wrong palace. This test proves the
5226    /// route now scopes the recall to the requested palace.
5227    /// What: creates two palaces with distinct drawers, requests recall with
5228    /// `palace=` set to one of them, and asserts the response is a JSON array
5229    /// (the per-palace shape), not the cross-palace object shape.
5230    /// Test: this test.
5231    #[tokio::test]
5232    async fn recall_all_handler_honors_palace_filter() {
5233        let state = test_state();
5234        // Pre-create a palace so the handler can open it.
5235        let palace = Palace {
5236            id: PalaceId::new("filter-target"),
5237            name: "filter-target".to_string(),
5238            description: None,
5239            created_at: chrono::Utc::now(),
5240            data_dir: state.data_root.join("filter-target"),
5241        };
5242        state
5243            .registry
5244            .create_palace(&state.data_root, palace)
5245            .expect("create_palace");
5246
5247        let app = router().with_state(state);
5248        // With palace= set, the handler should delegate to the per-palace path.
5249        // Even with no drawers, a valid palace returns a JSON array (possibly
5250        // empty), NOT a 404 or a cross-palace object shape.
5251        let resp = app
5252            .oneshot(
5253                Request::builder()
5254                    .uri("/api/v1/recall?q=anything&palace=filter-target")
5255                    .body(Body::empty())
5256                    .unwrap(),
5257            )
5258            .await
5259            .unwrap();
5260        assert_eq!(
5261            resp.status(),
5262            StatusCode::OK,
5263            "recall with valid palace= must return 200"
5264        );
5265        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
5266        let v: Value = serde_json::from_slice(&bytes).unwrap();
5267        assert!(
5268            v.is_array(),
5269            "recall with palace= must return a JSON array (per-palace shape); got {v}"
5270        );
5271    }
5272
5273    /// Why (issue #465): when `palace=` refers to a non-existent palace, the
5274    /// handler must return a 404 — not silently fall back to cross-palace recall.
5275    /// What: requests recall with a `palace=` that was never created and asserts
5276    /// the response is 404.
5277    /// Test: this test.
5278    #[tokio::test]
5279    async fn recall_all_handler_palace_filter_missing_palace_returns_404() {
5280        let state = test_state();
5281        let app = router().with_state(state);
5282        let resp = app
5283            .oneshot(
5284                Request::builder()
5285                    .uri("/api/v1/recall?q=anything&palace=nonexistent-palace")
5286                    .body(Body::empty())
5287                    .unwrap(),
5288            )
5289            .await
5290            .unwrap();
5291        assert_eq!(
5292            resp.status(),
5293            StatusCode::NOT_FOUND,
5294            "recall with palace= pointing to missing palace must return 404"
5295        );
5296    }
5297
5298    /// Why (issue #465): when `palace=` is absent, the endpoint must continue
5299    /// to fan out across all palaces (original cross-palace behaviour).
5300    /// What: with no palace= param and no palaces created, the cross-palace
5301    /// fan-out returns an empty JSON array (no palaces → nothing to search).
5302    /// Test: this test.
5303    #[tokio::test]
5304    async fn recall_all_handler_fans_out_without_palace_param() {
5305        let state = test_state();
5306        let app = router().with_state(state);
5307        let resp = app
5308            .oneshot(
5309                Request::builder()
5310                    .uri("/api/v1/recall?q=anything")
5311                    .body(Body::empty())
5312                    .unwrap(),
5313            )
5314            .await
5315            .unwrap();
5316        assert_eq!(
5317            resp.status(),
5318            StatusCode::OK,
5319            "cross-palace recall with no palace= must return 200"
5320        );
5321        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
5322        let v: Value = serde_json::from_slice(&bytes).unwrap();
5323        // No palaces → empty array.
5324        assert!(
5325            v.is_array(),
5326            "cross-palace recall must return a JSON array; got {v}"
5327        );
5328    }
5329
5330    // -------------------------------------------------------------------------
5331    // Issue #466 — POST /api/v1/remember must reject short content synchronously
5332    // -------------------------------------------------------------------------
5333
5334    /// Why (issue #466): content that is too short was silently dropped by the
5335    /// background worker while the HTTP response claimed `202 Accepted`.
5336    /// Callers believed the memory was stored when it wasn't — silent data loss.
5337    /// The fix: validate the minimum word count synchronously and return 422
5338    /// before queueing so the caller gets an actionable error immediately.
5339    /// What: POSTs content with fewer than REMEMBER_MIN_WORDS words and asserts
5340    /// the response is 422, not 202.
5341    /// Test: this test.
5342    #[tokio::test]
5343    async fn remember_async_rejects_short_content() {
5344        let state = test_state();
5345        let app = router().with_state(state);
5346        // "hi" is 1 word — well below REMEMBER_MIN_WORDS (4).
5347        for body in [
5348            json!({"content": "hi"}),
5349            json!({"content": "two words"}),
5350            json!({"content": "three word content"}),
5351        ] {
5352            let resp = app
5353                .clone()
5354                .oneshot(
5355                    Request::builder()
5356                        .method("POST")
5357                        .uri("/api/v1/remember")
5358                        .header("content-type", "application/json")
5359                        .body(Body::from(body.to_string()))
5360                        .unwrap(),
5361                )
5362                .await
5363                .unwrap();
5364            assert_eq!(
5365                resp.status(),
5366                StatusCode::UNPROCESSABLE_ENTITY,
5367                "short content must return 422; body={body}"
5368            );
5369        }
5370    }
5371
5372    /// Why (issue #466): content that meets the minimum word count must still
5373    /// return 202, proving the synchronous gate does not over-reject.
5374    /// What: POSTs exactly REMEMBER_MIN_WORDS words and asserts 202.
5375    /// Test: this test (companion to `remember_async_rejects_short_content`).
5376    #[tokio::test]
5377    async fn remember_async_accepts_content_at_min_words() {
5378        let state = test_state();
5379        // Pre-create a palace so the spawned task can find it.
5380        let palace = Palace {
5381            id: PalaceId::new("min-words-test"),
5382            name: "min-words-test".to_string(),
5383            description: None,
5384            created_at: chrono::Utc::now(),
5385            data_dir: state.data_root.join("min-words-test"),
5386        };
5387        state
5388            .registry
5389            .create_palace(&state.data_root, palace)
5390            .expect("create_palace");
5391
5392        let app = router().with_state(state);
5393        // Exactly 4 words — the minimum.
5394        let body = json!({
5395            "content": "four words exactly here",
5396            "palace": "min-words-test",
5397        });
5398        let resp = app
5399            .oneshot(
5400                Request::builder()
5401                    .method("POST")
5402                    .uri("/api/v1/remember")
5403                    .header("content-type", "application/json")
5404                    .body(Body::from(body.to_string()))
5405                    .unwrap(),
5406            )
5407            .await
5408            .unwrap();
5409        assert_eq!(
5410            resp.status(),
5411            StatusCode::ACCEPTED,
5412            "content at minimum word count must return 202"
5413        );
5414        let bytes = to_bytes(resp.into_body(), 512).await.unwrap();
5415        let v: Value = serde_json::from_slice(&bytes).unwrap();
5416        assert_eq!(
5417            v["status"], "queued",
5418            "accepted body must carry status=queued"
5419        );
5420    }
5421}