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