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