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