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