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        AppState::new(root)
1753    }
1754
1755    #[test]
1756    fn drawer_preview_collapses_whitespace_and_truncates() {
1757        // Short single-line content is returned verbatim.
1758        assert_eq!(drawer_content_preview("hello world"), "hello world");
1759
1760        // Multiline / tab-laden content collapses to single-spaced text.
1761        assert_eq!(
1762            drawer_content_preview("first line\n\nsecond\tline   third"),
1763            "first line second line third"
1764        );
1765
1766        // Leading / trailing whitespace is stripped.
1767        assert_eq!(drawer_content_preview("   padded   "), "padded");
1768
1769        // Empty content yields an empty preview (fallback signal for clients).
1770        assert_eq!(drawer_content_preview(""), "");
1771
1772        // Long content is truncated to DRAWER_PREVIEW_MAX_CHARS with an ellipsis.
1773        let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
1774        let preview = drawer_content_preview(&long);
1775        assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
1776        assert!(preview.ends_with('…'));
1777
1778        // Content right at the limit is not truncated.
1779        let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
1780        assert_eq!(drawer_content_preview(&exact), exact);
1781    }
1782
1783    /// `GET /health` returns HTTP 200 with `status: "ok"` after the
1784    /// round-trip clears every stage against the auto-provisioned probe palace.
1785    ///
1786    /// Why: confirms the JSON contract (`status`, `version`) for monitors that
1787    /// poll `/health`. Marked `#[ignore]` because issue #185 routes the probe
1788    /// through the dedicated palace and `recall_with_default_embedder` loads
1789    /// ONNX — too heavy for the default CI matrix. Run with
1790    /// `cargo test -p trusty-memory -- --include-ignored`.
1791    /// What: Drives `/health` and asserts the basic JSON keys.
1792    /// Test: this test.
1793    #[tokio::test]
1794    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1795    async fn health_endpoint_returns_ok() {
1796        let state = test_state();
1797        let app = router().with_state(state);
1798        let resp = app
1799            .oneshot(
1800                Request::builder()
1801                    .uri("/health")
1802                    .body(Body::empty())
1803                    .unwrap(),
1804            )
1805            .await
1806            .unwrap();
1807        assert_eq!(resp.status(), StatusCode::OK);
1808        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1809        let v: Value = serde_json::from_slice(&bytes).unwrap();
1810        assert_eq!(v["status"], "ok");
1811        assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
1812    }
1813
1814    /// Issue #35 — `GET /health` carries the enriched resource block
1815    /// (`rss_mb`, `disk_bytes`, `cpu_pct`, `uptime_secs`).
1816    ///
1817    /// Why: external probes and the admin UI render these; the JSON contract
1818    /// must remain stable. `rss_mb` is sampled live so it is asserted only
1819    /// for a sane unit, not an exact value. Marked `#[ignore]` because
1820    /// issue #185 makes every `/health` request run the full round-trip and
1821    /// `recall_with_default_embedder` loads the ONNX embedder.
1822    /// What: drives `/health` through the router and asserts every new field
1823    /// deserialises with a plausible value.
1824    /// Test: this test.
1825    #[tokio::test]
1826    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1827    async fn health_endpoint_includes_resource_fields() {
1828        let state = test_state();
1829        let app = router().with_state(state);
1830        let resp = app
1831            .oneshot(
1832                Request::builder()
1833                    .uri("/health")
1834                    .body(Body::empty())
1835                    .unwrap(),
1836            )
1837            .await
1838            .unwrap();
1839        assert_eq!(resp.status(), StatusCode::OK);
1840        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1841        let v: Value = serde_json::from_slice(&bytes).unwrap();
1842        // rss_mb must be a sane unit (megabytes, not bytes).
1843        let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
1844        assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
1845        // cpu_pct is a non-negative percentage (first sample may be 0.0).
1846        let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
1847        assert!(cpu >= 0.0, "cpu_pct must be non-negative");
1848        // disk ticker has not run in this oneshot test → 0.
1849        assert_eq!(v["disk_bytes"].as_u64(), Some(0));
1850        // uptime_secs is present and a u64.
1851        assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
1852    }
1853
1854    /// Issue #71 + #185 — `GET /health` reports `status: "ok"` on a fresh
1855    /// install by auto-provisioning the dedicated probe palace and running
1856    /// the full remember/recall/forget cycle against it.
1857    ///
1858    /// Why: Pre-#185 the handler short-circuited with "no palaces" on a fresh
1859    /// install, so a broken data plane would not surface until a real user
1860    /// created a palace. The dedicated `__health_probe__` palace removes that
1861    /// blind spot: the probe runs from boot. Marked `#[ignore]` because the
1862    /// round-trip now loads the ONNX embedder via `recall_with_default_embedder`,
1863    /// which is too heavy for the default CI matrix — run with
1864    /// `cargo test -p trusty-memory -- --include-ignored` for local verification.
1865    /// What: Drives `/health` through the router with an empty `data_root`
1866    /// and asserts `status == "ok"` (probe palace was auto-created and the
1867    /// round-trip cleared every stage) and the `detail` key is absent.
1868    /// Test: this test.
1869    #[tokio::test]
1870    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1871    async fn health_endpoint_round_trip_on_fresh_install_is_ok() {
1872        let state = test_state();
1873        let app = router().with_state(state);
1874        let resp = app
1875            .oneshot(
1876                Request::builder()
1877                    .uri("/health")
1878                    .body(Body::empty())
1879                    .unwrap(),
1880            )
1881            .await
1882            .unwrap();
1883        assert_eq!(resp.status(), StatusCode::OK);
1884        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1885        let v: Value = serde_json::from_slice(&bytes).unwrap();
1886        assert_eq!(v["status"], "ok");
1887        assert!(
1888            v.get("detail").is_none() || v["detail"].is_null(),
1889            "fresh-install health must not carry a degraded detail (got {v:?})"
1890        );
1891    }
1892
1893    /// Issue #71 — `GET /health` exercises the full store/recall/forget
1894    /// cycle against the first palace and reports `status: "ok"` on success.
1895    ///
1896    /// Why: The whole point of issue #71 is to catch store/recall
1897    /// regressions at probe time rather than via real client traffic. This
1898    /// test creates a real palace, hits `/health`, and asserts the
1899    /// round-trip path is happy. Marked `#[ignore]` because
1900    /// `recall_with_default_embedder` pulls in the ONNX model and is too
1901    /// heavy for the default CI matrix — run with
1902    /// `cargo test -p trusty-memory -- --include-ignored` for local
1903    /// verification.
1904    /// What: Builds an `AppState` with a tempdir `data_root`, creates a
1905    /// `health-probe-palace` via `registry.create_palace`, hits `/health`,
1906    /// and asserts both the status and the absence of any `detail` field.
1907    /// Test: this test.
1908    #[tokio::test]
1909    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1910    async fn health_endpoint_round_trip_with_palace_is_ok() {
1911        let state = test_state();
1912        let palace = trusty_common::memory_core::Palace {
1913            id: PalaceId::new("health-probe-palace"),
1914            name: "health-probe-palace".to_string(),
1915            description: None,
1916            created_at: chrono::Utc::now(),
1917            data_dir: state.data_root.join("health-probe-palace"),
1918        };
1919        state
1920            .registry
1921            .create_palace(&state.data_root, palace)
1922            .expect("create_palace");
1923
1924        let app = router().with_state(state);
1925        let resp = app
1926            .oneshot(
1927                Request::builder()
1928                    .uri("/health")
1929                    .body(Body::empty())
1930                    .unwrap(),
1931            )
1932            .await
1933            .unwrap();
1934        assert_eq!(resp.status(), StatusCode::OK);
1935        let bytes = to_bytes(resp.into_body(), 2048).await.unwrap();
1936        let v: Value = serde_json::from_slice(&bytes).unwrap();
1937        assert_eq!(
1938            v["status"], "ok",
1939            "round-trip should succeed against a fresh palace; got {v:?}"
1940        );
1941        assert!(
1942            v.get("detail").is_none() || v["detail"].is_null(),
1943            "successful round-trip must not carry a detail field (got {v:?})"
1944        );
1945    }
1946
1947    /// Issue #185 — the `__health_probe__` palace is hidden from
1948    /// `MemoryService::list_palaces`.
1949    ///
1950    /// Why: The dedicated health-probe palace exists on disk and must keep
1951    /// existing across restarts, but it is an internal implementation detail
1952    /// of `/health` and must never confuse the user (in the admin UI, TUI,
1953    /// chat-tool palace roster, etc.).
1954    /// What: Provisions the probe palace via the same helper the handler uses,
1955    /// confirms the directory exists on disk, then asks
1956    /// `MemoryService::list_palaces` for the user-facing roster and asserts
1957    /// no palace with the reserved id (or any `__`-prefixed id) is returned.
1958    /// Test: this test.
1959    #[tokio::test]
1960    async fn health_probe_palace_is_invisible() {
1961        let state = test_state();
1962        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
1963
1964        // The probe palace was persisted under the data root.
1965        assert!(
1966            state.data_root.join(HEALTH_PROBE_PALACE).exists(),
1967            "probe palace directory should be persisted on disk"
1968        );
1969
1970        let service = crate::service::MemoryService::new(state);
1971        let listed = service.list_palaces().await.expect("list_palaces");
1972        assert!(
1973            listed.iter().all(|p| !p.id.starts_with("__")),
1974            "no `__`-prefixed palace may appear in the user-facing list; got {:?}",
1975            listed.iter().map(|p| &p.id).collect::<Vec<_>>()
1976        );
1977        assert!(
1978            !listed.iter().any(|p| p.id == HEALTH_PROBE_PALACE),
1979            "the dedicated `__health_probe__` palace must be invisible; got {:?}",
1980            listed.iter().map(|p| &p.id).collect::<Vec<_>>()
1981        );
1982    }
1983
1984    /// Issue #185 — after a successful round-trip, the probe palace holds
1985    /// zero drawers.
1986    ///
1987    /// Why: The probe must clean up after itself on every success path. If
1988    /// the forget step were ever skipped silently, the probe palace would
1989    /// grow unbounded over time (the original symptom was ~1,420 leaked
1990    /// drawers in `localLLM`). This test pins the post-condition without
1991    /// requiring the heavy ONNX recall — it exercises
1992    /// `run_health_round_trip_inner` with a recall stub that returns a
1993    /// synthetic hit matching the probe drawer id.
1994    /// What: Provisions the probe palace, opens its handle, runs the inner
1995    /// round-trip with a stubbed recall that returns the probe drawer, and
1996    /// asserts the handle's drawer count drops back to zero.
1997    /// Test: this test.
1998    #[tokio::test]
1999    async fn health_probe_cleans_up_on_success() {
2000        use trusty_common::memory_core::Drawer;
2001
2002        let state = test_state();
2003        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2004        let handle = state
2005            .registry
2006            .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2007            .expect("open probe palace");
2008
2009        let result = run_health_round_trip_inner(handle.clone(), move |h, _query| async move {
2010            // Synthesize a hit that points at the most recently stored drawer
2011            // so the round-trip treats this as a successful recall.
2012            let drawers = h.drawers.read();
2013            let last = drawers
2014                .last()
2015                .cloned()
2016                .unwrap_or_else(|| Drawer::new(Uuid::new_v4(), "stub"));
2017            drop(drawers);
2018            Ok(vec![RecallResult {
2019                drawer: last,
2020                score: 1.0,
2021                layer: 1,
2022            }])
2023        })
2024        .await;
2025        assert!(
2026            result.is_ok(),
2027            "successful round-trip should return Ok; got {result:?}"
2028        );
2029
2030        let drawer_count = handle.drawers.read().len();
2031        assert_eq!(
2032            drawer_count, 0,
2033            "probe palace must have zero drawers after a successful round-trip (got {drawer_count})"
2034        );
2035    }
2036
2037    /// Issue #185 — when recall returns an empty result, the probe drawer is
2038    /// still deleted before the round-trip surfaces the failure.
2039    ///
2040    /// Why: This is the bug fix's central correctness property. Before #185
2041    /// the empty-result branch did `return Err(RecallMiss)` *before* calling
2042    /// `handle.forget(drawer_id)`, leaking the drawer. The new code calls
2043    /// forget unconditionally and then evaluates the recall outcome, so a
2044    /// recall miss can never leave a drawer behind.
2045    /// What: Drives `run_health_round_trip_inner` with a recall stub that
2046    /// returns an empty `Vec`, asserts the function reports
2047    /// `HealthProbeError::ProbeMissing`, and then asserts the probe palace
2048    /// is empty.
2049    /// Test: this test.
2050    #[tokio::test]
2051    async fn health_probe_cleans_up_on_recall_miss() {
2052        let state = test_state();
2053        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2054        let handle = state
2055            .registry
2056            .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2057            .expect("open probe palace");
2058
2059        let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2060            // Empty result — pre-#185 this leaked the drawer.
2061            Ok(Vec::new())
2062        })
2063        .await;
2064        assert!(
2065            matches!(result, Err(HealthProbeError::ProbeMissing(_))),
2066            "recall miss must surface as ProbeMissing; got {result:?}"
2067        );
2068
2069        let drawer_count = handle.drawers.read().len();
2070        assert_eq!(
2071            drawer_count, 0,
2072            "probe palace must be empty after a recall miss (got {drawer_count})"
2073        );
2074    }
2075
2076    /// Issue #185 — when recall errors out, the probe drawer is still
2077    /// deleted before the round-trip surfaces the failure.
2078    ///
2079    /// Why: The second leak mode pre-#185: `recall` returning `Err(_)` made
2080    /// the function `return Err(Recall(e))` before reaching `forget`. The
2081    /// fix calls forget unconditionally; this test guards that ordering by
2082    /// stubbing a recall that always errors and asserting the palace ends
2083    /// empty.
2084    /// What: Drives `run_health_round_trip_inner` with a recall stub that
2085    /// returns `Err(Recall(...))`, asserts the function surfaces a Recall
2086    /// error, and then asserts the probe palace is empty.
2087    /// Test: this test.
2088    #[tokio::test]
2089    async fn health_probe_cleans_up_on_recall_error() {
2090        let state = test_state();
2091        ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2092        let handle = state
2093            .registry
2094            .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2095            .expect("open probe palace");
2096
2097        let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2098            Err(HealthProbeError::Recall("simulated failure".to_string()))
2099        })
2100        .await;
2101        assert!(
2102            matches!(result, Err(HealthProbeError::Recall(_))),
2103            "recall error must surface as Recall; got {result:?}"
2104        );
2105
2106        let drawer_count = handle.drawers.read().len();
2107        assert_eq!(
2108            drawer_count, 0,
2109            "probe palace must be empty after a recall error (got {drawer_count})"
2110        );
2111    }
2112
2113    /// Issue #69 — `recall_entry_json` hoists the drawer's fields to the top
2114    /// level so `content` is directly reachable.
2115    ///
2116    /// Why: The recall API previously wrapped the drawer under a `"drawer"`
2117    /// key, so clients scanning the top level for `content`/`tags` found
2118    /// nothing and recall always looked empty. This locks the flattened shape
2119    /// in place so the regression cannot silently return.
2120    /// What: Builds a `RecallResult`, runs it through `recall_entry_json`, and
2121    /// asserts `content`, `tags`, and `importance` are at the top level, that
2122    /// `score`/`layer` sit alongside them, and that the old `drawer` wrapper
2123    /// key is gone.
2124    /// Test: this test.
2125    #[test]
2126    fn recall_entry_json_hoists_drawer_fields() {
2127        use trusty_common::memory_core::Drawer;
2128
2129        let room = Uuid::new_v4();
2130        let mut drawer = Drawer::new(room, "the answer is 42");
2131        drawer.tags = vec!["source:kuzu".to_string()];
2132        drawer.importance = 0.7;
2133
2134        let entry = recall_entry_json(RecallResult {
2135            drawer,
2136            score: 0.699,
2137            layer: 1,
2138        });
2139
2140        // Content must be reachable WITHOUT a `drawer` wrapper (issue #69).
2141        assert_eq!(
2142            entry.get("content").and_then(|v| v.as_str()),
2143            Some("the answer is 42"),
2144            "content must be at the top level, got {entry:?}"
2145        );
2146        assert!(
2147            entry.get("drawer").is_none(),
2148            "the legacy `drawer` wrapper must not be present, got {entry:?}"
2149        );
2150        // Other drawer fields are hoisted too.
2151        assert_eq!(
2152            entry["importance"].as_f64().map(|f| (f * 10.0).round()),
2153            Some(7.0)
2154        );
2155        assert_eq!(
2156            entry["tags"][0].as_str(),
2157            Some("source:kuzu"),
2158            "tags must be hoisted, got {entry:?}"
2159        );
2160        // Ranking metadata sits alongside the hoisted fields.
2161        assert_eq!(entry["layer"].as_u64(), Some(1));
2162        assert!(
2163            entry["score"]
2164                .as_f64()
2165                .is_some_and(|s| (s - 0.699).abs() < 1e-6),
2166            "score must be preserved, got {entry:?}"
2167        );
2168    }
2169
2170    /// Issue #35 — `GET /api/v1/logs/tail` returns the most recent buffered
2171    /// lines and the total count.
2172    ///
2173    /// Why: operators inspect a running daemon via this endpoint; it must
2174    /// surface exactly what the shared `LogBuffer` holds.
2175    /// What: attaches a `LogBuffer` to the state, pushes three lines, GETs
2176    /// `?n=2`, and asserts the tail + `total`.
2177    /// Test: this test.
2178    #[tokio::test]
2179    async fn logs_tail_returns_recent_lines() {
2180        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2181        buffer.push("line one".to_string());
2182        buffer.push("line two".to_string());
2183        buffer.push("line three".to_string());
2184        let state = test_state().with_log_buffer(buffer);
2185        let app = router().with_state(state);
2186        let resp = app
2187            .oneshot(
2188                Request::builder()
2189                    .uri("/api/v1/logs/tail?n=2")
2190                    .body(Body::empty())
2191                    .unwrap(),
2192            )
2193            .await
2194            .unwrap();
2195        assert_eq!(resp.status(), StatusCode::OK);
2196        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2197        let v: Value = serde_json::from_slice(&bytes).unwrap();
2198        let lines = v["lines"].as_array().expect("lines array");
2199        assert_eq!(lines.len(), 2, "n=2 must return two lines");
2200        assert_eq!(lines[0].as_str(), Some("line two"));
2201        assert_eq!(lines[1].as_str(), Some("line three"));
2202        assert_eq!(v["total"].as_u64(), Some(3));
2203    }
2204
2205    /// Issue #35 — `GET /api/v1/logs/tail?n=` is clamped to
2206    /// `[1, MAX_LOGS_TAIL_N]`.
2207    ///
2208    /// Why: a misconfigured client must not request more lines than the
2209    /// buffer holds, and `n=0` must still return at least one line.
2210    /// What: pushes five lines, requests `n=0` (clamps to 1) and an oversized
2211    /// `n` (clamps to the buffer length).
2212    /// Test: this test.
2213    #[tokio::test]
2214    async fn logs_tail_clamps_n() {
2215        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2216        for i in 0..5 {
2217            buffer.push(format!("l{i}"));
2218        }
2219        let state = test_state().with_log_buffer(buffer);
2220        let app = router().with_state(state);
2221
2222        // n=0 clamps up to 1.
2223        let resp = app
2224            .clone()
2225            .oneshot(
2226                Request::builder()
2227                    .uri("/api/v1/logs/tail?n=0")
2228                    .body(Body::empty())
2229                    .unwrap(),
2230            )
2231            .await
2232            .unwrap();
2233        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2234        let v: Value = serde_json::from_slice(&bytes).unwrap();
2235        assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2236
2237        // n far past MAX clamps down to the buffer length (5).
2238        let resp = app
2239            .oneshot(
2240                Request::builder()
2241                    .uri("/api/v1/logs/tail?n=999999")
2242                    .body(Body::empty())
2243                    .unwrap(),
2244            )
2245            .await
2246            .unwrap();
2247        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2248        let v: Value = serde_json::from_slice(&bytes).unwrap();
2249        assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2250    }
2251
2252    /// Issue #35 — `POST /api/v1/admin/stop` acknowledges the shutdown
2253    /// request with `{ ok, message }`.
2254    ///
2255    /// Why: the response shape is the documented contract for the admin UI's
2256    /// stop button.
2257    /// What: calls `admin_stop` directly and asserts the JSON body. It does
2258    /// NOT await the spawned exit task — that would terminate the test
2259    /// process — but the 200 ms delay before `process::exit` guarantees the
2260    /// test returns first.
2261    /// Test: this test.
2262    #[tokio::test]
2263    async fn admin_stop_returns_ok() {
2264        let state = test_state();
2265        let Json(body) = admin_stop(State(state)).await;
2266        assert_eq!(body["ok"], Value::Bool(true));
2267        assert_eq!(body["message"].as_str(), Some("shutting down"));
2268    }
2269
2270    #[tokio::test]
2271    async fn status_endpoint_returns_payload() {
2272        let state = test_state();
2273        let app = router().with_state(state);
2274        let resp = app
2275            .oneshot(
2276                Request::builder()
2277                    .uri("/api/v1/status")
2278                    .body(Body::empty())
2279                    .unwrap(),
2280            )
2281            .await
2282            .unwrap();
2283        assert_eq!(resp.status(), StatusCode::OK);
2284        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2285        let v: Value = serde_json::from_slice(&bytes).unwrap();
2286        assert!(v["version"].is_string());
2287        assert_eq!(v["palace_count"], 0);
2288    }
2289
2290    #[tokio::test]
2291    async fn unknown_api_returns_404() {
2292        let state = test_state();
2293        let app = router().with_state(state);
2294        let resp = app
2295            .oneshot(
2296                Request::builder()
2297                    .uri("/api/v1/does-not-exist")
2298                    .body(Body::empty())
2299                    .unwrap(),
2300            )
2301            .await
2302            .unwrap();
2303        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2304    }
2305
2306    /// Issue #70 — `…/memories` is a working alias for `…/drawers`.
2307    ///
2308    /// Why: Clients that POST/GET against `…/memories` previously hit a 404
2309    /// because only `/drawers` was registered, which silently broke every
2310    /// store call (and pushed callers onto an OOM-prone CLI fallback). The
2311    /// alias must route to the same handler as `/drawers`.
2312    /// What: Creates a real palace via the registry, then GETs the `/memories`
2313    /// alias and asserts a 200 with a JSON array body (the list-drawers shape).
2314    /// Uses GET, not POST, so the test stays embedder-free (no ONNX load).
2315    /// Test: this test.
2316    #[tokio::test]
2317    async fn memories_alias_routes_to_drawers() {
2318        let state = test_state();
2319        let palace = Palace {
2320            id: PalaceId::new("alias-test"),
2321            name: "alias-test".to_string(),
2322            description: None,
2323            created_at: chrono::Utc::now(),
2324            data_dir: state.data_root.join("alias-test"),
2325        };
2326        state
2327            .registry
2328            .create_palace(&state.data_root, palace)
2329            .expect("create_palace");
2330
2331        let app = router().with_state(state);
2332        let resp = app
2333            .oneshot(
2334                Request::builder()
2335                    .uri("/api/v1/palaces/alias-test/memories")
2336                    .body(Body::empty())
2337                    .unwrap(),
2338            )
2339            .await
2340            .unwrap();
2341        assert_eq!(
2342            resp.status(),
2343            StatusCode::OK,
2344            "the /memories alias must resolve to list_drawers, not 404"
2345        );
2346        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2347        let v: Value = serde_json::from_slice(&bytes).unwrap();
2348        assert!(
2349            v.is_array(),
2350            "the alias must return the list-drawers array shape, got {v:?}"
2351        );
2352    }
2353
2354    /// Issue #133 — `POST /api/v1/palaces/{id}/drawers` must trigger the
2355    /// same auto-KG extraction as the MCP `memory_remember` tool.
2356    ///
2357    /// Why: PR #106 wired auto-extract only into the MCP path; HTTP-origin
2358    /// writes silently skipped it, leaving every palace populated via the
2359    /// HTTP API with an empty KG. This regression test posts a drawer over
2360    /// HTTP and then queries the KG to confirm the expected `tag:`,
2361    /// `room:`, and `topic:` (`#hashtag`) auto-extracted triples landed.
2362    /// What: creates a palace via the registry, posts a drawer with tags +
2363    /// room + a `#hashtag` over the HTTP endpoint, reads
2364    /// `/api/v1/palaces/{id}/kg/graph`, and asserts the auto-extracted
2365    /// triples (provenance = `auto:remember`) appear.
2366    /// Test: this test.
2367    #[tokio::test]
2368    async fn http_create_drawer_runs_auto_kg_extraction() {
2369        let state = test_state();
2370        let palace = Palace {
2371            id: PalaceId::new("kgauto-http"),
2372            name: "kgauto-http".to_string(),
2373            description: None,
2374            created_at: chrono::Utc::now(),
2375            data_dir: state.data_root.join("kgauto-http"),
2376        };
2377        state
2378            .registry
2379            .create_palace(&state.data_root, palace)
2380            .expect("create_palace");
2381
2382        let app = router().with_state(state.clone());
2383        // Why: tag "test" is in the KG extraction deny-list (issue #278), so we
2384        // use "backend" and "kg" tags to exercise the auto-extraction path
2385        // without triggering the deny-list skip.
2386        let body = json!({
2387            "content": "trusty-memory is a Rust crate that ships an MCP server. \
2388                        It tracks #mcp and #rust topics with care.",
2389            "room": "Backend",
2390            "tags": ["backend", "kg"],
2391            "importance": 0.5,
2392        })
2393        .to_string();
2394        let resp = app
2395            .clone()
2396            .oneshot(
2397                Request::builder()
2398                    .method("POST")
2399                    .uri("/api/v1/palaces/kgauto-http/drawers")
2400                    .header("content-type", "application/json")
2401                    .body(Body::from(body))
2402                    .unwrap(),
2403            )
2404            .await
2405            .unwrap();
2406        assert_eq!(
2407            resp.status(),
2408            StatusCode::OK,
2409            "create_drawer must return 200 OK"
2410        );
2411
2412        // Read the KG graph for the same palace and assert auto-extracted
2413        // triples landed. The exact set is exercised in
2414        // `tools::tests::auto_kg_extraction_hooks_into_memory_remember`; here
2415        // we only need to confirm the HTTP path now mirrors the MCP path.
2416        let resp = app
2417            .oneshot(
2418                Request::builder()
2419                    .uri("/api/v1/palaces/kgauto-http/kg/graph")
2420                    .body(Body::empty())
2421                    .unwrap(),
2422            )
2423            .await
2424            .unwrap();
2425        assert_eq!(resp.status(), StatusCode::OK);
2426        let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
2427        let v: Value = serde_json::from_slice(&bytes).unwrap();
2428        let triples = v["triples"].as_array().expect("triples array");
2429        assert!(
2430            !triples.is_empty(),
2431            "HTTP-origin drawer must populate the KG; got empty graph"
2432        );
2433        let auto: Vec<&Value> = triples
2434            .iter()
2435            .filter(|t| t["provenance"].as_str() == Some(crate::kg_extract::AUTO_PROVENANCE))
2436            .collect();
2437        assert!(
2438            !auto.is_empty(),
2439            "expected at least one auto-extracted triple in HTTP-populated KG; got: {triples:?}"
2440        );
2441        // Spot-check the tag-as-subject encoding survived (matches the MCP
2442        // path's behaviour and proves the extractor saw the body's tags).
2443        // Note: "test" is in the deny-list, so we use "backend" in the drawer
2444        // tags above (issue #278); assert on that tag instead.
2445        assert!(
2446            auto.iter()
2447                .any(|t| t["subject"].as_str() == Some("tag:backend")),
2448            "expected `tag:backend` auto-extracted edge, got: {auto:?}"
2449        );
2450        // Hashtag mention triples (room-aware extractor).
2451        assert!(
2452            auto.iter()
2453                .any(|t| t["predicate"].as_str() == Some("mentioned-in")),
2454            "expected at least one #hashtag mention triple, got: {auto:?}"
2455        );
2456    }
2457
2458    #[tokio::test]
2459    async fn create_then_list_palace() {
2460        let state = test_state();
2461        let app = router().with_state(state.clone());
2462        let body = json!({"name": "web-test", "description": "from test"}).to_string();
2463        let resp = app
2464            .clone()
2465            .oneshot(
2466                Request::builder()
2467                    .method("POST")
2468                    .uri("/api/v1/palaces")
2469                    .header("content-type", "application/json")
2470                    .body(Body::from(body))
2471                    .unwrap(),
2472            )
2473            .await
2474            .unwrap();
2475        assert_eq!(resp.status(), StatusCode::OK);
2476
2477        let resp = app
2478            .oneshot(
2479                Request::builder()
2480                    .uri("/api/v1/palaces")
2481                    .body(Body::empty())
2482                    .unwrap(),
2483            )
2484            .await
2485            .unwrap();
2486        assert_eq!(resp.status(), StatusCode::OK);
2487        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2488        let v: Value = serde_json::from_slice(&bytes).unwrap();
2489        let arr = v.as_array().expect("array");
2490        assert!(arr.iter().any(|p| p["id"] == "web-test"));
2491    }
2492
2493    /// Why: Issue #180 — verify the happy path: create an empty palace,
2494    /// `DELETE /api/v1/palaces/{id}` returns 204, and a follow-up
2495    /// `GET /api/v1/palaces/{id}` returns 404 because the directory is gone.
2496    /// What: Drives the router through axum's `oneshot` testing layer; no
2497    /// query parameters are passed so `force` defaults to `false`. A freshly
2498    /// created palace has no drawers, so the conflict guard does not fire.
2499    /// Test: This test itself.
2500    #[tokio::test]
2501    async fn delete_palace_removes_dir_when_empty() {
2502        let state = test_state();
2503        let app = router().with_state(state.clone());
2504        let body = json!({"name": "to-delete"}).to_string();
2505        let resp = app
2506            .clone()
2507            .oneshot(
2508                Request::builder()
2509                    .method("POST")
2510                    .uri("/api/v1/palaces")
2511                    .header("content-type", "application/json")
2512                    .body(Body::from(body))
2513                    .unwrap(),
2514            )
2515            .await
2516            .unwrap();
2517        assert_eq!(resp.status(), StatusCode::OK);
2518
2519        let resp = app
2520            .clone()
2521            .oneshot(
2522                Request::builder()
2523                    .method("DELETE")
2524                    .uri("/api/v1/palaces/to-delete")
2525                    .body(Body::empty())
2526                    .unwrap(),
2527            )
2528            .await
2529            .unwrap();
2530        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2531
2532        // Confirm the palace is gone from the on-disk registry.
2533        let resp = app
2534            .oneshot(
2535                Request::builder()
2536                    .uri("/api/v1/palaces/to-delete")
2537                    .body(Body::empty())
2538                    .unwrap(),
2539            )
2540            .await
2541            .unwrap();
2542        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2543
2544        // And the on-disk directory itself was removed.
2545        let palace_dir = state.data_root.join("to-delete");
2546        assert!(
2547            !palace_dir.exists(),
2548            "palace dir should be removed: {}",
2549            palace_dir.display()
2550        );
2551    }
2552
2553    /// Why: Issue #180 — without `force=true` we must refuse to drop a
2554    /// palace that still has drawers, otherwise a stray DELETE could nuke
2555    /// hours of memory in one request.
2556    /// What: Create a palace, write a drawer into it, then DELETE without
2557    /// `force`. Expect 409 Conflict and verify the palace and drawer are
2558    /// still on disk.
2559    /// Test: This test itself.
2560    #[tokio::test]
2561    async fn delete_palace_refuses_when_drawers_present() {
2562        let state = test_state();
2563        let app = router().with_state(state.clone());
2564        // Create the palace.
2565        let resp = app
2566            .clone()
2567            .oneshot(
2568                Request::builder()
2569                    .method("POST")
2570                    .uri("/api/v1/palaces")
2571                    .header("content-type", "application/json")
2572                    .body(Body::from(json!({"name": "keep-me"}).to_string()))
2573                    .unwrap(),
2574            )
2575            .await
2576            .unwrap();
2577        assert_eq!(resp.status(), StatusCode::OK);
2578        // Add a drawer so the conflict guard fires.
2579        let resp = app
2580            .clone()
2581            .oneshot(
2582                Request::builder()
2583                    .method("POST")
2584                    .uri("/api/v1/palaces/keep-me/drawers")
2585                    .header("content-type", "application/json")
2586                    .body(Body::from(
2587                        json!({
2588                            "content": "Important fact that should not be deleted accidentally.",
2589                            "tags": [],
2590                        })
2591                        .to_string(),
2592                    ))
2593                    .unwrap(),
2594            )
2595            .await
2596            .unwrap();
2597        assert_eq!(resp.status(), StatusCode::OK);
2598
2599        let resp = app
2600            .clone()
2601            .oneshot(
2602                Request::builder()
2603                    .method("DELETE")
2604                    .uri("/api/v1/palaces/keep-me")
2605                    .body(Body::empty())
2606                    .unwrap(),
2607            )
2608            .await
2609            .unwrap();
2610        assert_eq!(resp.status(), StatusCode::CONFLICT);
2611
2612        // Palace still resolves.
2613        let resp = app
2614            .oneshot(
2615                Request::builder()
2616                    .uri("/api/v1/palaces/keep-me")
2617                    .body(Body::empty())
2618                    .unwrap(),
2619            )
2620            .await
2621            .unwrap();
2622        assert_eq!(resp.status(), StatusCode::OK);
2623    }
2624
2625    /// Why: Issue #180 — `?force=true` is the explicit destructive opt-in;
2626    /// the conflict guard must yield and the palace must vanish even with
2627    /// drawers present.
2628    /// What: Same setup as the conflict test, but pass `?force=true` and
2629    /// assert the 204 + 404 follow-up shape.
2630    /// Test: This test itself.
2631    #[tokio::test]
2632    async fn delete_palace_force_removes_populated_palace() {
2633        let state = test_state();
2634        let app = router().with_state(state.clone());
2635        let resp = app
2636            .clone()
2637            .oneshot(
2638                Request::builder()
2639                    .method("POST")
2640                    .uri("/api/v1/palaces")
2641                    .header("content-type", "application/json")
2642                    .body(Body::from(json!({"name": "force-delete"}).to_string()))
2643                    .unwrap(),
2644            )
2645            .await
2646            .unwrap();
2647        assert_eq!(resp.status(), StatusCode::OK);
2648        let resp = app
2649            .clone()
2650            .oneshot(
2651                Request::builder()
2652                    .method("POST")
2653                    .uri("/api/v1/palaces/force-delete/drawers")
2654                    .header("content-type", "application/json")
2655                    .body(Body::from(
2656                        json!({"content": "Sacrificial drawer for the force-delete path.", "tags": []}).to_string(),
2657                    ))
2658                    .unwrap(),
2659            )
2660            .await
2661            .unwrap();
2662        assert_eq!(resp.status(), StatusCode::OK);
2663
2664        let resp = app
2665            .clone()
2666            .oneshot(
2667                Request::builder()
2668                    .method("DELETE")
2669                    .uri("/api/v1/palaces/force-delete?force=true")
2670                    .body(Body::empty())
2671                    .unwrap(),
2672            )
2673            .await
2674            .unwrap();
2675        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2676
2677        let resp = app
2678            .oneshot(
2679                Request::builder()
2680                    .uri("/api/v1/palaces/force-delete")
2681                    .body(Body::empty())
2682                    .unwrap(),
2683            )
2684            .await
2685            .unwrap();
2686        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2687    }
2688
2689    /// Why: Issue #180 — deleting a missing palace must yield 404 so
2690    /// idempotent retries on the client are distinguishable from the
2691    /// "drawers present" precondition failure.
2692    /// What: DELETE against a never-created id and assert 404.
2693    /// Test: This test itself.
2694    #[tokio::test]
2695    async fn delete_palace_returns_not_found_for_missing_id() {
2696        let state = test_state();
2697        let app = router().with_state(state);
2698        let resp = app
2699            .oneshot(
2700                Request::builder()
2701                    .method("DELETE")
2702                    .uri("/api/v1/palaces/never-existed")
2703                    .body(Body::empty())
2704                    .unwrap(),
2705            )
2706            .await
2707            .unwrap();
2708        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2709    }
2710
2711    /// Why: Issue #180 follow-up — verify the happy path of `PATCH
2712    /// /api/v1/palaces/{id}`: create a palace, rename it, and confirm
2713    /// `GET /api/v1/palaces/{id}` returns the new display name. The id
2714    /// (which is the on-disk directory) must stay stable.
2715    /// What: POST a palace named "rename-me", PATCH with a new display
2716    /// name, expect 200 + payload showing the rename, then GET to confirm
2717    /// persistence to disk.
2718    /// Test: This test itself.
2719    #[tokio::test]
2720    async fn update_palace_name_renames_palace() {
2721        let state = test_state();
2722        let app = router().with_state(state);
2723        let resp = app
2724            .clone()
2725            .oneshot(
2726                Request::builder()
2727                    .method("POST")
2728                    .uri("/api/v1/palaces")
2729                    .header("content-type", "application/json")
2730                    .body(Body::from(json!({"name": "rename-me"}).to_string()))
2731                    .unwrap(),
2732            )
2733            .await
2734            .unwrap();
2735        assert_eq!(resp.status(), StatusCode::OK);
2736
2737        let resp = app
2738            .clone()
2739            .oneshot(
2740                Request::builder()
2741                    .method("PATCH")
2742                    .uri("/api/v1/palaces/rename-me")
2743                    .header("content-type", "application/json")
2744                    .body(Body::from(json!({"name": "New Display Name"}).to_string()))
2745                    .unwrap(),
2746            )
2747            .await
2748            .unwrap();
2749        assert_eq!(resp.status(), StatusCode::OK);
2750        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2751        let v: Value = serde_json::from_slice(&bytes).unwrap();
2752        assert_eq!(v["id"].as_str(), Some("rename-me"));
2753        assert_eq!(v["name"].as_str(), Some("New Display Name"));
2754
2755        let resp = app
2756            .oneshot(
2757                Request::builder()
2758                    .uri("/api/v1/palaces/rename-me")
2759                    .body(Body::empty())
2760                    .unwrap(),
2761            )
2762            .await
2763            .unwrap();
2764        assert_eq!(resp.status(), StatusCode::OK);
2765        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2766        let v: Value = serde_json::from_slice(&bytes).unwrap();
2767        assert_eq!(v["id"].as_str(), Some("rename-me"));
2768        assert_eq!(v["name"].as_str(), Some("New Display Name"));
2769    }
2770
2771    /// Why: Issue #180 follow-up — empty / whitespace-only names would
2772    /// break the dashboard label. Reject with 400 so the caller knows the
2773    /// request was well-formed but the value is invalid.
2774    /// What: Create a palace, PATCH with `{"name": "   "}`, expect 400.
2775    /// Test: This test itself.
2776    #[tokio::test]
2777    async fn update_palace_name_rejects_empty_name() {
2778        let state = test_state();
2779        let app = router().with_state(state);
2780        let resp = app
2781            .clone()
2782            .oneshot(
2783                Request::builder()
2784                    .method("POST")
2785                    .uri("/api/v1/palaces")
2786                    .header("content-type", "application/json")
2787                    .body(Body::from(json!({"name": "keep-name"}).to_string()))
2788                    .unwrap(),
2789            )
2790            .await
2791            .unwrap();
2792        assert_eq!(resp.status(), StatusCode::OK);
2793
2794        let resp = app
2795            .oneshot(
2796                Request::builder()
2797                    .method("PATCH")
2798                    .uri("/api/v1/palaces/keep-name")
2799                    .header("content-type", "application/json")
2800                    .body(Body::from(json!({"name": "   "}).to_string()))
2801                    .unwrap(),
2802            )
2803            .await
2804            .unwrap();
2805        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2806    }
2807
2808    /// Why: Issue #180 follow-up — patching a non-existent palace must
2809    /// yield 404 so retries against the wrong id surface the real problem
2810    /// rather than silently no-op'ing.
2811    /// What: PATCH against a never-created id and assert 404.
2812    /// Test: This test itself.
2813    #[tokio::test]
2814    async fn update_palace_name_returns_not_found_for_missing_id() {
2815        let state = test_state();
2816        let app = router().with_state(state);
2817        let resp = app
2818            .oneshot(
2819                Request::builder()
2820                    .method("PATCH")
2821                    .uri("/api/v1/palaces/no-such-palace")
2822                    .header("content-type", "application/json")
2823                    .body(Body::from(json!({"name": "irrelevant"}).to_string()))
2824                    .unwrap(),
2825            )
2826            .await
2827            .unwrap();
2828        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2829    }
2830
2831    /// Why: The operator TUI's MEMORY tab reads `node_count`, `edge_count`,
2832    /// `community_count`, and `is_compacting` straight off the
2833    /// `/api/v1/palaces` payload. If any of those fields disappear or change
2834    /// type the spinner / counters break silently. Pin the shape here.
2835    /// What: Creates a palace, lists `/api/v1/palaces`, and asserts every new
2836    /// field is present and typed as expected (numbers default to 0, the
2837    /// compacting flag defaults to false on a freshly-opened palace).
2838    /// Test: This test itself.
2839    #[tokio::test]
2840    async fn palace_list_includes_graph_counts() {
2841        let state = test_state();
2842        let app = router().with_state(state.clone());
2843        let body = json!({"name": "graph-counts", "description": null}).to_string();
2844        let resp = app
2845            .clone()
2846            .oneshot(
2847                Request::builder()
2848                    .method("POST")
2849                    .uri("/api/v1/palaces")
2850                    .header("content-type", "application/json")
2851                    .body(Body::from(body))
2852                    .unwrap(),
2853            )
2854            .await
2855            .unwrap();
2856        assert_eq!(resp.status(), StatusCode::OK);
2857
2858        let resp = app
2859            .oneshot(
2860                Request::builder()
2861                    .uri("/api/v1/palaces")
2862                    .body(Body::empty())
2863                    .unwrap(),
2864            )
2865            .await
2866            .unwrap();
2867        assert_eq!(resp.status(), StatusCode::OK);
2868        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2869        let v: Value = serde_json::from_slice(&bytes).unwrap();
2870        let arr = v.as_array().expect("array");
2871        let row = arr
2872            .iter()
2873            .find(|p| p["id"] == "graph-counts")
2874            .expect("created palace must appear in list");
2875        assert_eq!(row["node_count"].as_u64(), Some(0));
2876        assert_eq!(row["edge_count"].as_u64(), Some(0));
2877        assert_eq!(row["community_count"].as_u64(), Some(0));
2878        assert_eq!(row["is_compacting"].as_bool(), Some(false));
2879    }
2880
2881    /// Why: The enriched status payload backs the dashboard's top-row stats;
2882    /// it must always include the new total_* counters, even on an empty data
2883    /// root, so the UI can render zeros without special-casing missing fields.
2884    /// What: Hit `/api/v1/status` on a fresh state and assert the new fields
2885    /// are present and set to 0.
2886    /// Test: This test itself.
2887    #[tokio::test]
2888    async fn status_includes_total_counters() {
2889        let state = test_state();
2890        let app = router().with_state(state);
2891        let resp = app
2892            .oneshot(
2893                Request::builder()
2894                    .uri("/api/v1/status")
2895                    .body(Body::empty())
2896                    .unwrap(),
2897            )
2898            .await
2899            .unwrap();
2900        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2901        let v: Value = serde_json::from_slice(&bytes).unwrap();
2902        assert_eq!(v["total_drawers"], 0);
2903        assert_eq!(v["total_vectors"], 0);
2904        assert_eq!(v["total_kg_triples"], 0);
2905    }
2906
2907    /// Why: `/api/v1/dream/status` must return a well-shaped payload even
2908    /// when no palace has ever run a dream cycle (so the dashboard's first
2909    /// load doesn't error).
2910    /// What: Hit the endpoint on a fresh state and assert `last_run_at` is
2911    /// null and the counters are zero.
2912    /// Test: This test itself.
2913    #[tokio::test]
2914    async fn dream_status_empty_returns_nulls() {
2915        let state = test_state();
2916        let app = router().with_state(state);
2917        let resp = app
2918            .oneshot(
2919                Request::builder()
2920                    .uri("/api/v1/dream/status")
2921                    .body(Body::empty())
2922                    .unwrap(),
2923            )
2924            .await
2925            .unwrap();
2926        assert_eq!(resp.status(), StatusCode::OK);
2927        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2928        let v: Value = serde_json::from_slice(&bytes).unwrap();
2929        assert!(v["last_run_at"].is_null());
2930        assert_eq!(v["merged"], 0);
2931        assert_eq!(v["pruned"], 0);
2932    }
2933
2934    /// Why: `/api/v1/chat/providers` must return a well-shaped payload even
2935    /// when no provider is available, so the SPA can render disabled states
2936    /// without special-casing missing fields.
2937    /// What: Hit the endpoint on a fresh state; assert it returns `providers`
2938    /// (an array of length 2) and an `active` field (possibly null).
2939    /// Test: This test itself.
2940    #[tokio::test]
2941    async fn providers_endpoint_returns_payload() {
2942        let state = test_state();
2943        let app = router().with_state(state);
2944        let resp = app
2945            .oneshot(
2946                Request::builder()
2947                    .uri("/api/v1/chat/providers")
2948                    .body(Body::empty())
2949                    .unwrap(),
2950            )
2951            .await
2952            .unwrap();
2953        assert_eq!(resp.status(), StatusCode::OK);
2954        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2955        let v: Value = serde_json::from_slice(&bytes).unwrap();
2956        let arr = v["providers"].as_array().expect("providers array");
2957        assert_eq!(arr.len(), 2);
2958        let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
2959        assert!(names.contains(&"ollama"));
2960        assert!(names.contains(&"openrouter"));
2961        // `active` may be null when no provider is configured/reachable.
2962        assert!(v.get("active").is_some());
2963    }
2964
2965    /// Why: Chat-session CRUD must round-trip end-to-end through the HTTP
2966    /// surface — create returns an id, list shows it, get returns the
2967    /// (empty) history, delete removes it.
2968    /// What: Create a palace, then exercise the four session endpoints
2969    /// sequentially, asserting JSON shapes at each step.
2970    /// Test: This test itself.
2971    #[tokio::test]
2972    async fn chat_session_crud_round_trip() {
2973        let state = test_state();
2974        // Pre-create a palace dir so session store has a place to live.
2975        let palace = trusty_common::memory_core::Palace {
2976            id: PalaceId::new("sess-test"),
2977            name: "sess-test".to_string(),
2978            description: None,
2979            created_at: chrono::Utc::now(),
2980            data_dir: state.data_root.join("sess-test"),
2981        };
2982        state
2983            .registry
2984            .create_palace(&state.data_root, palace)
2985            .expect("create_palace");
2986        let app = router().with_state(state);
2987
2988        // Create
2989        let resp = app
2990            .clone()
2991            .oneshot(
2992                Request::builder()
2993                    .method("POST")
2994                    .uri("/api/v1/palaces/sess-test/chat/sessions")
2995                    .header("content-type", "application/json")
2996                    .body(Body::from(json!({"title":"first chat"}).to_string()))
2997                    .unwrap(),
2998            )
2999            .await
3000            .unwrap();
3001        assert_eq!(resp.status(), StatusCode::OK);
3002        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3003        let v: Value = serde_json::from_slice(&bytes).unwrap();
3004        let sid = v["id"].as_str().expect("session id").to_string();
3005
3006        // List
3007        let resp = app
3008            .clone()
3009            .oneshot(
3010                Request::builder()
3011                    .uri("/api/v1/palaces/sess-test/chat/sessions")
3012                    .body(Body::empty())
3013                    .unwrap(),
3014            )
3015            .await
3016            .unwrap();
3017        assert_eq!(resp.status(), StatusCode::OK);
3018        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3019        let v: Value = serde_json::from_slice(&bytes).unwrap();
3020        let arr = v.as_array().expect("array");
3021        assert!(arr.iter().any(|s| s["id"] == sid));
3022
3023        // Get
3024        let resp = app
3025            .clone()
3026            .oneshot(
3027                Request::builder()
3028                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3029                    .body(Body::empty())
3030                    .unwrap(),
3031            )
3032            .await
3033            .unwrap();
3034        assert_eq!(resp.status(), StatusCode::OK);
3035
3036        // Delete
3037        let resp = app
3038            .clone()
3039            .oneshot(
3040                Request::builder()
3041                    .method("DELETE")
3042                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3043                    .body(Body::empty())
3044                    .unwrap(),
3045            )
3046            .await
3047            .unwrap();
3048        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3049
3050        // Get after delete -> 404
3051        let resp = app
3052            .oneshot(
3053                Request::builder()
3054                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3055                    .body(Body::empty())
3056                    .unwrap(),
3057            )
3058            .await
3059            .unwrap();
3060        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3061    }
3062
3063    /// Why: issue #99 — the HTTP surface for inter-project messaging is what
3064    /// `trusty-memory send-message` and `trusty-memory inbox-check` both
3065    /// drive. We pin the round-trip (send → list-unread → mark-read →
3066    /// list-empty) so a future refactor cannot accidentally break either
3067    /// CLI without a failing test.
3068    /// What: pre-creates the recipient palace, POSTs a message, asserts
3069    /// `unread_only=true` returns exactly one entry with the right
3070    /// envelope fields, POSTs to mark_read, asserts the unread inbox is
3071    /// now empty, and confirms the audit view (`unread_only=false`) still
3072    /// surfaces the read message.
3073    /// Test: this test itself.
3074    #[tokio::test]
3075    async fn messages_endpoint_round_trip() {
3076        let state = test_state();
3077        let palace = trusty_common::memory_core::Palace {
3078            id: PalaceId::new("msg-test"),
3079            name: "msg-test".to_string(),
3080            description: None,
3081            created_at: chrono::Utc::now(),
3082            data_dir: state.data_root.join("msg-test"),
3083        };
3084        state
3085            .registry
3086            .create_palace(&state.data_root, palace)
3087            .expect("create_palace");
3088        let app = router().with_state(state);
3089
3090        // POST /api/v1/messages — send.
3091        let resp = app
3092            .clone()
3093            .oneshot(
3094                Request::builder()
3095                    .method("POST")
3096                    .uri("/api/v1/messages")
3097                    .header("content-type", "application/json")
3098                    .body(Body::from(
3099                        json!({
3100                            "to_palace":   "msg-test",
3101                            "from_palace": "sender-palace",
3102                            "purpose":     "task",
3103                            "content":     "please refresh schema"
3104                        })
3105                        .to_string(),
3106                    ))
3107                    .unwrap(),
3108            )
3109            .await
3110            .unwrap();
3111        assert_eq!(resp.status(), StatusCode::OK);
3112        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3113        let send_resp: Value = serde_json::from_slice(&bytes).unwrap();
3114        assert_eq!(send_resp["status"], "sent");
3115        let drawer_id = send_resp["drawer_id"]
3116            .as_str()
3117            .expect("drawer_id")
3118            .to_string();
3119
3120        // GET unread inbox.
3121        let resp = app
3122            .clone()
3123            .oneshot(
3124                Request::builder()
3125                    .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3126                    .body(Body::empty())
3127                    .unwrap(),
3128            )
3129            .await
3130            .unwrap();
3131        assert_eq!(resp.status(), StatusCode::OK);
3132        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3133        let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3134        assert_eq!(list.len(), 1);
3135        assert_eq!(list[0]["id"], drawer_id);
3136        assert_eq!(list[0]["from_palace"], "sender-palace");
3137        assert_eq!(list[0]["to_palace"], "msg-test");
3138        assert_eq!(list[0]["purpose"], "task");
3139        assert_eq!(list[0]["content"], "please refresh schema");
3140        assert_eq!(list[0]["read"], false);
3141        assert!(list[0]["formatted"]
3142            .as_str()
3143            .unwrap()
3144            .contains("sender-palace"));
3145
3146        // POST mark_read.
3147        let resp = app
3148            .clone()
3149            .oneshot(
3150                Request::builder()
3151                    .method("POST")
3152                    .uri("/api/v1/messages/mark_read")
3153                    .header("content-type", "application/json")
3154                    .body(Body::from(
3155                        json!({"palace": "msg-test", "drawer_id": drawer_id}).to_string(),
3156                    ))
3157                    .unwrap(),
3158            )
3159            .await
3160            .unwrap();
3161        assert_eq!(resp.status(), StatusCode::OK);
3162        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
3163        let mark: Value = serde_json::from_slice(&bytes).unwrap();
3164        assert_eq!(mark["flipped"], true);
3165
3166        // GET unread again — empty.
3167        let resp = app
3168            .clone()
3169            .oneshot(
3170                Request::builder()
3171                    .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3172                    .body(Body::empty())
3173                    .unwrap(),
3174            )
3175            .await
3176            .unwrap();
3177        assert_eq!(resp.status(), StatusCode::OK);
3178        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3179        let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3180        assert!(list.is_empty(), "inbox cleared after mark_read");
3181
3182        // GET history (unread_only=false) — still has the message, now read.
3183        let resp = app
3184            .oneshot(
3185                Request::builder()
3186                    .uri("/api/v1/messages?palace=msg-test&unread_only=false")
3187                    .body(Body::empty())
3188                    .unwrap(),
3189            )
3190            .await
3191            .unwrap();
3192        assert_eq!(resp.status(), StatusCode::OK);
3193        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3194        let history: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3195        assert_eq!(history.len(), 1);
3196        assert_eq!(history[0]["read"], true);
3197    }
3198
3199    /// Why: The chat assistant's tool surface is part of the public API — any
3200    /// drift in tool names or required-argument lists is a breaking change for
3201    /// the UI and any external automation. Pin the shape here so a refactor
3202    /// has to acknowledge it.
3203    /// What: Snapshots the names + every tool's `required` array.
3204    /// Test: This test itself.
3205    #[test]
3206    fn all_tools_returns_expected_set() {
3207        let tools = crate::chat::all_tools();
3208        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3209        assert_eq!(
3210            names,
3211            vec![
3212                "list_palaces",
3213                "get_palace",
3214                "recall_memories",
3215                "list_drawers",
3216                "kg_query",
3217                "get_config",
3218                "get_status",
3219                "get_dream_status",
3220                "get_palace_dream_status",
3221                "create_memory",
3222                "kg_assert",
3223                "memory_recall_all",
3224            ]
3225        );
3226        // Every tool's `parameters` must be a JSON Schema object with a
3227        // `required` array (possibly empty).
3228        for t in &tools {
3229            assert_eq!(
3230                t.parameters["type"], "object",
3231                "tool {} schema type",
3232                t.name
3233            );
3234            assert!(
3235                t.parameters["required"].is_array(),
3236                "tool {} required not array",
3237                t.name
3238            );
3239        }
3240    }
3241
3242    /// Why: `execute_tool` is the bridge between the model's tool_call
3243    /// arguments and the live Rust core. We exercise the happy path
3244    /// (`list_palaces` on an empty registry returns `[]`) and the unknown-
3245    /// tool path (returns `{"error": "..."}`) to lock down both branches.
3246    /// What: Calls execute_tool against a fresh `AppState`.
3247    /// Test: This test itself.
3248    #[tokio::test]
3249    async fn execute_tool_dispatches_known_tools() {
3250        let state = test_state();
3251        let result = crate::chat::execute_tool("list_palaces", "{}", &state).await;
3252        assert!(
3253            result.is_array(),
3254            "list_palaces should be array, got {result}"
3255        );
3256        assert_eq!(result.as_array().unwrap().len(), 0);
3257
3258        let unknown = crate::chat::execute_tool("not_a_tool", "{}", &state).await;
3259        assert!(
3260            unknown["error"]
3261                .as_str()
3262                .unwrap_or("")
3263                .contains("unknown tool"),
3264            "expected unknown-tool error, got {unknown}"
3265        );
3266
3267        let missing = crate::chat::execute_tool("get_palace", "{}", &state).await;
3268        assert!(
3269            missing["error"]
3270                .as_str()
3271                .unwrap_or("")
3272                .contains("palace_id"),
3273            "expected missing-arg error, got {missing}"
3274        );
3275    }
3276
3277    /// Why: The SSE event bus is the dashboard's live-update transport;
3278    /// regressing it would silently break the UI. Subscribing before the
3279    /// emit guarantees the broadcast channel has a receiver when the
3280    /// handler fires, so we can deterministically observe the event.
3281    /// What: Subscribes to `state.events`, calls the `create_palace`
3282    /// handler through the router, then asserts a `PalaceCreated` event
3283    /// (and a follow-up status event from drawer mutation) flow through.
3284    /// Test: `cargo test -p trusty-memory-mcp sse_broadcast_emits_palace_created`.
3285    #[tokio::test]
3286    async fn sse_broadcast_emits_palace_created() {
3287        let state = test_state();
3288        let mut rx = state.events.subscribe();
3289        let app = router().with_state(state.clone());
3290        let body = json!({"name": "sse-test"}).to_string();
3291        let resp = app
3292            .oneshot(
3293                Request::builder()
3294                    .method("POST")
3295                    .uri("/api/v1/palaces")
3296                    .header("content-type", "application/json")
3297                    .body(Body::from(body))
3298                    .unwrap(),
3299            )
3300            .await
3301            .unwrap();
3302        assert_eq!(resp.status(), StatusCode::OK);
3303        // The handler should have emitted PalaceCreated before returning.
3304        let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3305            .await
3306            .expect("event received within timeout")
3307            .expect("event channel still open");
3308        match event {
3309            DaemonEvent::PalaceCreated { id, name, source } => {
3310                assert_eq!(id, "sse-test");
3311                assert_eq!(name, "sse-test");
3312                assert_eq!(source, ActivitySource::Http);
3313            }
3314            other => panic!("expected PalaceCreated, got {other:?}"),
3315        }
3316    }
3317
3318    /// Why: Confirm the `/sse` endpoint speaks `text/event-stream` and emits
3319    /// the initial `connected` frame so dashboard clients can rely on a
3320    /// known greeting.
3321    /// What: Issues a GET against `/sse`, reads the response body chunk,
3322    /// asserts the content-type header and the first SSE frame shape.
3323    /// Test: `cargo test -p trusty-memory-mcp sse_endpoint_emits_connected_frame`.
3324    #[tokio::test]
3325    async fn sse_endpoint_emits_connected_frame() {
3326        use axum::routing::get;
3327        let state = test_state();
3328        let app = router()
3329            .route("/sse", get(crate::sse_handler))
3330            .with_state(state);
3331        let resp = app
3332            .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3333            .await
3334            .unwrap();
3335        assert_eq!(resp.status(), StatusCode::OK);
3336        assert_eq!(
3337            resp.headers()
3338                .get(header::CONTENT_TYPE)
3339                .and_then(|v| v.to_str().ok()),
3340            Some("text/event-stream")
3341        );
3342        // Read just the first chunk (the connected frame) — the stream stays
3343        // open otherwise, so we use a small read budget plus timeout.
3344        let body = resp.into_body();
3345        let bytes =
3346            tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3347                .await
3348                .ok()
3349                .and_then(|r| r.ok())
3350                .unwrap_or_default();
3351        let text = String::from_utf8_lossy(&bytes);
3352        assert!(
3353            text.contains("\"type\":\"connected\""),
3354            "expected connected frame, got: {text}"
3355        );
3356    }
3357
3358    /// Why: `/api/v1/dream/status` must sum per-palace `dream_stats.json`
3359    /// counters and surface the most recent `last_run_at`. A regression that
3360    /// returned only the first palace's stats would silently break the
3361    /// "global dream activity" dashboard panel.
3362    /// What: Pre-seeds two palace dirs under the AppState root, writes a
3363    /// distinct `PersistedDreamStats` JSON file into each, hits the endpoint,
3364    /// and asserts the integer fields are summed and `last_run_at` equals the
3365    /// newer of the two timestamps.
3366    /// Test: This test itself.
3367    #[tokio::test]
3368    async fn dream_status_aggregates_across_palaces() {
3369        use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3370
3371        let state = test_state();
3372        // Two palace directories — each must contain a `palace.json` so
3373        // `PalaceRegistry::list_palaces` sees them, plus a `dream_stats.json`
3374        // with distinct counter values.
3375        for (id, stats, ts) in [
3376            (
3377                "palace-a",
3378                DreamStats {
3379                    merged: 1,
3380                    pruned: 2,
3381                    compacted: 3,
3382                    closets_updated: 4,
3383                    duration_ms: 100,
3384                    ..DreamStats::default()
3385                },
3386                chrono::Utc::now() - chrono::Duration::seconds(60),
3387            ),
3388            (
3389                "palace-b",
3390                DreamStats {
3391                    merged: 10,
3392                    pruned: 20,
3393                    compacted: 30,
3394                    closets_updated: 40,
3395                    duration_ms: 200,
3396                    ..DreamStats::default()
3397                },
3398                chrono::Utc::now(),
3399            ),
3400        ] {
3401            let palace = trusty_common::memory_core::Palace {
3402                id: PalaceId::new(id),
3403                name: id.to_string(),
3404                description: None,
3405                created_at: chrono::Utc::now(),
3406                data_dir: state.data_root.join(id),
3407            };
3408            state
3409                .registry
3410                .create_palace(&state.data_root, palace)
3411                .expect("create palace");
3412            let persisted = PersistedDreamStats {
3413                last_run_at: ts,
3414                stats,
3415            };
3416            persisted
3417                .save(&state.data_root.join(id))
3418                .expect("save dream stats");
3419        }
3420
3421        let later = chrono::Utc::now();
3422        let app = router().with_state(state);
3423        let resp = app
3424            .oneshot(
3425                Request::builder()
3426                    .uri("/api/v1/dream/status")
3427                    .body(Body::empty())
3428                    .unwrap(),
3429            )
3430            .await
3431            .unwrap();
3432        assert_eq!(resp.status(), StatusCode::OK);
3433        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3434        let v: Value = serde_json::from_slice(&bytes).unwrap();
3435
3436        // Aggregated counters.
3437        assert_eq!(v["merged"], 11);
3438        assert_eq!(v["pruned"], 22);
3439        assert_eq!(v["compacted"], 33);
3440        assert_eq!(v["closets_updated"], 44);
3441        assert_eq!(v["duration_ms"], 300);
3442
3443        // `last_run_at` is the more-recent of the two timestamps.
3444        let last = v["last_run_at"].as_str().expect("last_run_at is string");
3445        let parsed: chrono::DateTime<chrono::Utc> = last
3446            .parse()
3447            .expect("last_run_at parses as RFC3339 timestamp");
3448        assert!(
3449            parsed <= later,
3450            "last_run_at ({parsed}) should not exceed wall clock ({later})"
3451        );
3452        // Must have picked palace-b's newer stamp, not palace-a's older one.
3453        let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3454        assert!(
3455            parsed >= cutoff,
3456            "expected the newer (palace-b) timestamp; got {parsed}"
3457        );
3458    }
3459
3460    /// Why: `POST /api/v1/dream/run` triggers a dream cycle across every
3461    /// palace and must return the aggregated stats. Even when no palace
3462    /// has work to do (empty registry) the endpoint must round-trip 200
3463    /// with the well-formed payload shape so the dashboard's "Run now"
3464    /// button never fails the UI.
3465    /// What: Pre-creates one palace via the registry, posts to the endpoint,
3466    /// and asserts the response is 200 with all expected fields present.
3467    /// Deeper assertions (specific merged/pruned counts) are skipped here
3468    /// because running a full dream cycle requires the ONNX embedder load
3469    /// path and we want this test to stay fast and embedder-free.
3470    /// Test: This test itself.
3471    #[tokio::test]
3472    async fn dream_run_aggregates_stats() {
3473        let state = test_state();
3474        let palace = trusty_common::memory_core::Palace {
3475            id: PalaceId::new("dream-run-test"),
3476            name: "dream-run-test".to_string(),
3477            description: None,
3478            created_at: chrono::Utc::now(),
3479            data_dir: state.data_root.join("dream-run-test"),
3480        };
3481        state
3482            .registry
3483            .create_palace(&state.data_root, palace)
3484            .expect("create palace");
3485
3486        let app = router().with_state(state);
3487        let resp = app
3488            .oneshot(
3489                Request::builder()
3490                    .method("POST")
3491                    .uri("/api/v1/dream/run")
3492                    .body(Body::empty())
3493                    .unwrap(),
3494            )
3495            .await
3496            .unwrap();
3497        assert_eq!(resp.status(), StatusCode::OK);
3498        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3499        let v: Value = serde_json::from_slice(&bytes).unwrap();
3500
3501        // Shape: every aggregated counter must be present (even if zero) and
3502        // `last_run_at` is set by the handler to "now".
3503        for key in [
3504            "merged",
3505            "pruned",
3506            "compacted",
3507            "closets_updated",
3508            "duration_ms",
3509        ] {
3510            assert!(
3511                v.get(key).is_some(),
3512                "missing key {key} in dream_run payload: {v}"
3513            );
3514            assert!(
3515                v[key].is_u64() || v[key].is_i64(),
3516                "{key} should be integer, got {}",
3517                v[key]
3518            );
3519        }
3520        assert!(
3521            v["last_run_at"].is_string(),
3522            "last_run_at must be set by dream_run; got {v}"
3523        );
3524    }
3525
3526    /// Why: Issue #53 — when the dream cycle has not yet run for a palace,
3527    /// `/api/v1/kg/gaps` must return an empty array (200 OK), not 404 or
3528    /// 500. The cache miss is a meaningful, non-error state.
3529    /// What: Creates a palace, queries `/api/v1/kg/gaps?palace=...`, asserts
3530    /// the response is `200` with body `[]`.
3531    /// Test: this test itself.
3532    #[tokio::test]
3533    async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3534        let state = test_state();
3535        let palace = trusty_common::memory_core::Palace {
3536            id: PalaceId::new("gaps-empty"),
3537            name: "gaps-empty".to_string(),
3538            description: None,
3539            created_at: chrono::Utc::now(),
3540            data_dir: state.data_root.join("gaps-empty"),
3541        };
3542        state
3543            .registry
3544            .create_palace(&state.data_root, palace)
3545            .expect("create palace");
3546
3547        let app = router().with_state(state);
3548        let resp = app
3549            .oneshot(
3550                Request::builder()
3551                    .uri("/api/v1/kg/gaps?palace=gaps-empty")
3552                    .body(Body::empty())
3553                    .unwrap(),
3554            )
3555            .await
3556            .unwrap();
3557        assert_eq!(resp.status(), StatusCode::OK);
3558        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3559        let v: Value = serde_json::from_slice(&bytes).unwrap();
3560        assert_eq!(v.as_array().expect("array").len(), 0);
3561    }
3562
3563    /// Why: Issue #53 — when the cache *has* been populated (by the dream
3564    /// cycle in production, or by direct seeding here), the endpoint must
3565    /// return each gap with the four wire fields.
3566    /// What: Seeds the registry cache via `set_gaps` directly, then GETs
3567    /// `/api/v1/kg/gaps?palace=...` and asserts the JSON shape.
3568    /// Test: this test itself.
3569    #[tokio::test]
3570    async fn kg_gaps_endpoint_returns_cached_gaps() {
3571        use trusty_common::memory_core::community::KnowledgeGap;
3572
3573        let state = test_state();
3574        let palace = trusty_common::memory_core::Palace {
3575            id: PalaceId::new("gaps-seed"),
3576            name: "gaps-seed".to_string(),
3577            description: None,
3578            created_at: chrono::Utc::now(),
3579            data_dir: state.data_root.join("gaps-seed"),
3580        };
3581        state
3582            .registry
3583            .create_palace(&state.data_root, palace)
3584            .expect("create palace");
3585
3586        state.registry.set_gaps(
3587            PalaceId::new("gaps-seed"),
3588            vec![KnowledgeGap {
3589                entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
3590                internal_density: 0.15,
3591                external_bridges: 2,
3592                suggested_exploration: "Explore connections between foo and related concepts"
3593                    .to_string(),
3594            }],
3595        );
3596
3597        let app = router().with_state(state);
3598        let resp = app
3599            .oneshot(
3600                Request::builder()
3601                    .uri("/api/v1/kg/gaps?palace=gaps-seed")
3602                    .body(Body::empty())
3603                    .unwrap(),
3604            )
3605            .await
3606            .unwrap();
3607        assert_eq!(resp.status(), StatusCode::OK);
3608        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3609        let v: Value = serde_json::from_slice(&bytes).unwrap();
3610        let arr = v.as_array().expect("array");
3611        assert_eq!(arr.len(), 1);
3612        assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
3613        assert_eq!(arr[0]["external_bridges"], 2);
3614        assert!(arr[0]["suggested_exploration"]
3615            .as_str()
3616            .unwrap()
3617            .contains("foo"));
3618    }
3619
3620    /// Why: The KG Explorer UI calls `/api/v1/palaces/{id}/kg/subjects` to
3621    /// populate the left panel; the endpoint must return distinct active
3622    /// subjects as a JSON string array.
3623    /// What: Creates a palace, asserts two triples via the existing kg endpoint,
3624    /// then GETs the subjects route and asserts the shape.
3625    /// Test: this test itself.
3626    #[tokio::test]
3627    async fn kg_list_subjects_returns_distinct() {
3628        let state = test_state();
3629        let app = router().with_state(state.clone());
3630
3631        // Create palace.
3632        let resp = app
3633            .clone()
3634            .oneshot(
3635                Request::builder()
3636                    .method("POST")
3637                    .uri("/api/v1/palaces")
3638                    .header("content-type", "application/json")
3639                    .body(Body::from(json!({"name": "kg-list"}).to_string()))
3640                    .unwrap(),
3641            )
3642            .await
3643            .unwrap();
3644        assert_eq!(resp.status(), StatusCode::OK);
3645
3646        // Assert two triples on distinct subjects.
3647        for subj in ["alpha", "beta"] {
3648            let body = json!({
3649                "subject": subj,
3650                "predicate": "is",
3651                "object": "thing",
3652            })
3653            .to_string();
3654            let r = app
3655                .clone()
3656                .oneshot(
3657                    Request::builder()
3658                        .method("POST")
3659                        .uri("/api/v1/palaces/kg-list/kg")
3660                        .header("content-type", "application/json")
3661                        .body(Body::from(body))
3662                        .unwrap(),
3663                )
3664                .await
3665                .unwrap();
3666            assert_eq!(r.status(), StatusCode::NO_CONTENT);
3667        }
3668
3669        let resp = app
3670            .oneshot(
3671                Request::builder()
3672                    .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
3673                    .body(Body::empty())
3674                    .unwrap(),
3675            )
3676            .await
3677            .unwrap();
3678        assert_eq!(resp.status(), StatusCode::OK);
3679        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3680        let v: Value = serde_json::from_slice(&bytes).unwrap();
3681        let arr = v.as_array().expect("subjects must be array");
3682        let subjects: Vec<String> = arr
3683            .iter()
3684            .filter_map(|x| x.as_str().map(String::from))
3685            .collect();
3686        assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
3687    }
3688
3689    /// Why: KG Explorer's "All" mode pages through every active triple via
3690    /// `/api/v1/palaces/{id}/kg/all`; the endpoint must return a JSON array of
3691    /// `Triple` rows ordered by `valid_from` DESC.
3692    /// What: Creates a palace, asserts a triple, then GETs the all route and
3693    /// asserts the response is an array with the expected shape.
3694    /// Test: this test itself.
3695    #[tokio::test]
3696    async fn kg_list_all_returns_paginated_triples() {
3697        let state = test_state();
3698        let app = router().with_state(state.clone());
3699
3700        let resp = app
3701            .clone()
3702            .oneshot(
3703                Request::builder()
3704                    .method("POST")
3705                    .uri("/api/v1/palaces")
3706                    .header("content-type", "application/json")
3707                    .body(Body::from(json!({"name": "kg-all"}).to_string()))
3708                    .unwrap(),
3709            )
3710            .await
3711            .unwrap();
3712        assert_eq!(resp.status(), StatusCode::OK);
3713
3714        let body = json!({
3715            "subject": "alpha",
3716            "predicate": "is",
3717            "object": "thing",
3718        })
3719        .to_string();
3720        let r = app
3721            .clone()
3722            .oneshot(
3723                Request::builder()
3724                    .method("POST")
3725                    .uri("/api/v1/palaces/kg-all/kg")
3726                    .header("content-type", "application/json")
3727                    .body(Body::from(body))
3728                    .unwrap(),
3729            )
3730            .await
3731            .unwrap();
3732        assert_eq!(r.status(), StatusCode::NO_CONTENT);
3733
3734        let resp = app
3735            .oneshot(
3736                Request::builder()
3737                    .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
3738                    .body(Body::empty())
3739                    .unwrap(),
3740            )
3741            .await
3742            .unwrap();
3743        assert_eq!(resp.status(), StatusCode::OK);
3744        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3745        let v: Value = serde_json::from_slice(&bytes).unwrap();
3746        let arr = v.as_array().expect("triples must be array");
3747        assert_eq!(arr.len(), 1);
3748        assert_eq!(arr[0]["subject"], "alpha");
3749        assert_eq!(arr[0]["predicate"], "is");
3750        assert_eq!(arr[0]["object"], "thing");
3751    }
3752
3753    /// Why (issue #97): The visual graph view fetches the entire active
3754    /// triple set in one call so d3-force can lay it out without paging.
3755    /// The endpoint must return the triple list plus the node/edge/
3756    /// community counts that drive the legend.
3757    /// What: Creates a palace, asserts a single triple, and confirms `GET
3758    /// /api/v1/palaces/{id}/kg/graph` returns `{ triples, node_count,
3759    /// edge_count, community_count }` with the right shape.
3760    /// Test: This test.
3761    #[tokio::test]
3762    async fn kg_graph_returns_active_triples() {
3763        let state = test_state();
3764        let app = router().with_state(state.clone());
3765
3766        let resp = app
3767            .clone()
3768            .oneshot(
3769                Request::builder()
3770                    .method("POST")
3771                    .uri("/api/v1/palaces")
3772                    .header("content-type", "application/json")
3773                    .body(Body::from(json!({"name": "kg-graph"}).to_string()))
3774                    .unwrap(),
3775            )
3776            .await
3777            .unwrap();
3778        assert_eq!(resp.status(), StatusCode::OK);
3779
3780        let body = json!({
3781            "subject": "alpha",
3782            "predicate": "is",
3783            "object": "thing",
3784        })
3785        .to_string();
3786        let r = app
3787            .clone()
3788            .oneshot(
3789                Request::builder()
3790                    .method("POST")
3791                    .uri("/api/v1/palaces/kg-graph/kg")
3792                    .header("content-type", "application/json")
3793                    .body(Body::from(body))
3794                    .unwrap(),
3795            )
3796            .await
3797            .unwrap();
3798        assert_eq!(r.status(), StatusCode::NO_CONTENT);
3799
3800        let resp = app
3801            .oneshot(
3802                Request::builder()
3803                    .uri("/api/v1/palaces/kg-graph/kg/graph")
3804                    .body(Body::empty())
3805                    .unwrap(),
3806            )
3807            .await
3808            .unwrap();
3809        assert_eq!(resp.status(), StatusCode::OK);
3810        let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
3811        let v: Value = serde_json::from_slice(&bytes).unwrap();
3812        let triples = v["triples"].as_array().expect("triples array");
3813        assert!(triples
3814            .iter()
3815            .any(|t| t["subject"] == "alpha" && t["predicate"] == "is" && t["object"] == "thing"));
3816        assert!(v["node_count"].as_u64().is_some());
3817        assert!(v["edge_count"].as_u64().is_some());
3818        assert!(v["community_count"].as_u64().is_some());
3819    }
3820
3821    /// Why (issue #97): The visual graph view's stated perf budget is
3822    /// "<1s for palaces with <500 triples". Seed 500 triples, time one
3823    /// `/kg/graph` round-trip, and assert the result stays well under that
3824    /// budget. The assertion uses a generous 10x ceiling so flaky CI
3825    /// hardware doesn't false-positive while still catching catastrophic
3826    /// regressions.
3827    /// What: Creates a palace, asserts 500 triples directly through the
3828    /// `KnowledgeGraph` handle (skipping the HTTP overhead of 500 separate
3829    /// `POST /kg` calls), then runs one `GET /kg/graph` and prints the
3830    /// elapsed time to stderr.
3831    /// Test: This test.
3832    #[tokio::test]
3833    async fn kg_graph_meets_perf_budget_for_500_triples() {
3834        let state = test_state();
3835        let app = router().with_state(state.clone());
3836
3837        let resp = app
3838            .clone()
3839            .oneshot(
3840                Request::builder()
3841                    .method("POST")
3842                    .uri("/api/v1/palaces")
3843                    .header("content-type", "application/json")
3844                    .body(Body::from(json!({"name": "kg-perf"}).to_string()))
3845                    .unwrap(),
3846            )
3847            .await
3848            .unwrap();
3849        assert_eq!(resp.status(), StatusCode::OK);
3850
3851        let pid = trusty_common::memory_core::palace::PalaceId::new("kg-perf");
3852        let handle = state
3853            .registry
3854            .open_palace(&state.data_root, &pid)
3855            .expect("open palace");
3856        let now = chrono::Utc::now();
3857        for s in 0..10 {
3858            for o in 0..50 {
3859                handle
3860                    .kg
3861                    .assert(Triple {
3862                        subject: format!("s{s}"),
3863                        predicate: format!("p{o}"),
3864                        object: format!("o{o}"),
3865                        valid_from: now,
3866                        valid_to: None,
3867                        confidence: 1.0,
3868                        provenance: Some("perf-test".to_string()),
3869                    })
3870                    .await
3871                    .expect("kg.assert");
3872            }
3873        }
3874
3875        let started = std::time::Instant::now();
3876        let resp = app
3877            .oneshot(
3878                Request::builder()
3879                    .uri("/api/v1/palaces/kg-perf/kg/graph")
3880                    .body(Body::empty())
3881                    .unwrap(),
3882            )
3883            .await
3884            .unwrap();
3885        let elapsed = started.elapsed();
3886        assert_eq!(resp.status(), StatusCode::OK);
3887        let bytes = to_bytes(resp.into_body(), 1_000_000).await.unwrap();
3888        let v: Value = serde_json::from_slice(&bytes).unwrap();
3889        let n = v["triples"].as_array().map(|a| a.len()).unwrap_or(0);
3890        assert_eq!(n, 500, "expected 500 triples in payload");
3891        assert!(
3892            elapsed.as_secs_f64() < 10.0,
3893            "graph endpoint should serve 500 triples in well under 10s; took {elapsed:?}"
3894        );
3895        eprintln!(
3896            "[perf] kg_graph endpoint served 500 triples in {:.3}ms",
3897            elapsed.as_secs_f64() * 1000.0
3898        );
3899    }
3900
3901    /// Why (issue #42): `GET /api/v1/kg/prompt-context` must serve the
3902    /// formatted Markdown block from the in-memory cache (or a placeholder
3903    /// when empty). Mirrors the MCP `get_prompt_context` tool but over HTTP.
3904    #[tokio::test]
3905    async fn prompt_context_endpoint_returns_formatted_block() {
3906        let state = test_state();
3907
3908        // Empty cache returns the placeholder text.
3909        let app = router().with_state(state.clone());
3910        let resp = app
3911            .oneshot(
3912                Request::builder()
3913                    .uri("/api/v1/kg/prompt-context")
3914                    .body(Body::empty())
3915                    .unwrap(),
3916            )
3917            .await
3918            .unwrap();
3919        assert_eq!(resp.status(), StatusCode::OK);
3920        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3921        let text = String::from_utf8(bytes.to_vec()).unwrap();
3922        assert_eq!(text, "No prompt facts stored yet.");
3923
3924        // Populate the cache and re-fetch.
3925        {
3926            let mut guard = state.prompt_context_cache.write().await;
3927            let triples = vec![(
3928                "tga".to_string(),
3929                "is_alias_for".to_string(),
3930                "trusty-git-analytics".to_string(),
3931            )];
3932            let formatted = crate::prompt_facts::build_prompt_context(&triples);
3933            *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
3934        }
3935        let app = router().with_state(state);
3936        let resp = app
3937            .oneshot(
3938                Request::builder()
3939                    .uri("/api/v1/kg/prompt-context")
3940                    .body(Body::empty())
3941                    .unwrap(),
3942            )
3943            .await
3944            .unwrap();
3945        assert_eq!(resp.status(), StatusCode::OK);
3946        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3947        let text = String::from_utf8(bytes.to_vec()).unwrap();
3948        assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
3949    }
3950
3951    /// Why (issue #42): `POST /api/v1/kg/aliases` must assert the alias as
3952    /// an `is_alias_for` triple AND refresh the prompt cache so subsequent
3953    /// reads see the new alias.
3954    #[tokio::test]
3955    async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
3956        let tmp = tempfile::tempdir().expect("tempdir");
3957        let root = tmp.path().to_path_buf();
3958        std::mem::forget(tmp);
3959        let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
3960        let palace = trusty_common::memory_core::Palace {
3961            id: PalaceId::new("aliases"),
3962            name: "aliases".to_string(),
3963            description: None,
3964            created_at: chrono::Utc::now(),
3965            data_dir: state.data_root.join("aliases"),
3966        };
3967        state
3968            .registry
3969            .create_palace(&state.data_root, palace)
3970            .expect("create palace");
3971
3972        let body = json!({"short": "tm", "full": "trusty-memory"});
3973        let app = router().with_state(state.clone());
3974        let resp = app
3975            .oneshot(
3976                Request::builder()
3977                    .method("POST")
3978                    .uri("/api/v1/kg/aliases")
3979                    .header("content-type", "application/json")
3980                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
3981                    .unwrap(),
3982            )
3983            .await
3984            .unwrap();
3985        assert_eq!(resp.status(), StatusCode::OK);
3986        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3987        let v: Value = serde_json::from_slice(&bytes).unwrap();
3988        assert_eq!(v["subject"], "tm");
3989        assert_eq!(v["object"], "trusty-memory");
3990
3991        // The prompt cache must reflect the new alias.
3992        let guard = state.prompt_context_cache.read().await;
3993        assert!(
3994            guard.formatted.contains("tm → trusty-memory"),
3995            "cache missing alias; got: {}",
3996            guard.formatted
3997        );
3998    }
3999
4000    /// Why (issue #42): `GET /api/v1/kg/prompt-facts` returns the structured
4001    /// JSON array of every hot-predicate triple across the registry (so a
4002    /// dashboard can render its own table).
4003    #[tokio::test]
4004    async fn list_prompt_facts_endpoint_returns_hot_triples() {
4005        let tmp = tempfile::tempdir().expect("tempdir");
4006        let root = tmp.path().to_path_buf();
4007        std::mem::forget(tmp);
4008        let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
4009        let palace = trusty_common::memory_core::Palace {
4010            id: PalaceId::new("listfacts"),
4011            name: "listfacts".to_string(),
4012            description: None,
4013            created_at: chrono::Utc::now(),
4014            data_dir: state.data_root.join("listfacts"),
4015        };
4016        let handle = state
4017            .registry
4018            .create_palace(&state.data_root, palace)
4019            .expect("create palace");
4020
4021        // Insert one hot triple and one non-hot triple; only the hot one
4022        // should surface.
4023        handle
4024            .kg
4025            .assert(Triple {
4026                subject: "ts".to_string(),
4027                predicate: "is_alias_for".to_string(),
4028                object: "trusty-search".to_string(),
4029                valid_from: chrono::Utc::now(),
4030                valid_to: None,
4031                confidence: 1.0,
4032                provenance: None,
4033            })
4034            .await
4035            .expect("assert alias");
4036        handle
4037            .kg
4038            .assert(Triple {
4039                subject: "alice".to_string(),
4040                predicate: "works_at".to_string(),
4041                object: "Acme".to_string(),
4042                valid_from: chrono::Utc::now(),
4043                valid_to: None,
4044                confidence: 1.0,
4045                provenance: None,
4046            })
4047            .await
4048            .expect("assert works_at");
4049
4050        let app = router().with_state(state);
4051        let resp = app
4052            .oneshot(
4053                Request::builder()
4054                    .uri("/api/v1/kg/prompt-facts")
4055                    .body(Body::empty())
4056                    .unwrap(),
4057            )
4058            .await
4059            .unwrap();
4060        assert_eq!(resp.status(), StatusCode::OK);
4061        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4062        let v: Value = serde_json::from_slice(&bytes).unwrap();
4063        let arr = v.as_array().expect("array");
4064        assert!(
4065            arr.iter().any(|r| r["subject"] == "ts"
4066                && r["predicate"] == "is_alias_for"
4067                && r["object"] == "trusty-search"),
4068            "missing ts alias; got {arr:?}"
4069        );
4070        // The non-hot `works_at` triple must not be present.
4071        assert!(
4072            !arr.iter().any(|r| r["predicate"] == "works_at"),
4073            "non-hot triple leaked into prompt facts: {arr:?}"
4074        );
4075    }
4076
4077    /// Why (issue #42): `DELETE /api/v1/kg/prompt-facts` must retract the
4078    /// interval and refresh the cache; the next list call must omit it.
4079    #[tokio::test]
4080    async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
4081        let tmp = tempfile::tempdir().expect("tempdir");
4082        let root = tmp.path().to_path_buf();
4083        std::mem::forget(tmp);
4084        let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
4085        let palace = trusty_common::memory_core::Palace {
4086            id: PalaceId::new("rmfacts"),
4087            name: "rmfacts".to_string(),
4088            description: None,
4089            created_at: chrono::Utc::now(),
4090            data_dir: state.data_root.join("rmfacts"),
4091        };
4092        let handle = state
4093            .registry
4094            .create_palace(&state.data_root, palace)
4095            .expect("create palace");
4096
4097        handle
4098            .kg
4099            .assert(Triple {
4100                subject: "ta".to_string(),
4101                predicate: "is_alias_for".to_string(),
4102                object: "trusty-analyze".to_string(),
4103                valid_from: chrono::Utc::now(),
4104                valid_to: None,
4105                confidence: 1.0,
4106                provenance: None,
4107            })
4108            .await
4109            .expect("assert alias");
4110        // Prime the cache so we can observe the removal effect.
4111        crate::prompt_facts::rebuild_prompt_cache(&state)
4112            .await
4113            .expect("rebuild prompt cache");
4114
4115        let app = router().with_state(state.clone());
4116        let resp = app
4117            .oneshot(
4118                Request::builder()
4119                    .method("DELETE")
4120                    .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
4121                    .body(Body::empty())
4122                    .unwrap(),
4123            )
4124            .await
4125            .unwrap();
4126        assert_eq!(resp.status(), StatusCode::OK);
4127        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4128        let v: Value = serde_json::from_slice(&bytes).unwrap();
4129        assert_eq!(v["removed"], true);
4130        assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
4131
4132        // Cache must no longer contain the alias.
4133        {
4134            let guard = state.prompt_context_cache.read().await;
4135            assert!(
4136                !guard.formatted.contains("ta → trusty-analyze"),
4137                "alias still in cache after delete: {}",
4138                guard.formatted
4139            );
4140        }
4141
4142        // Removing a non-existent fact returns removed=false.
4143        let app = router().with_state(state);
4144        let resp = app
4145            .oneshot(
4146                Request::builder()
4147                    .method("DELETE")
4148                    .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
4149                    .body(Body::empty())
4150                    .unwrap(),
4151            )
4152            .await
4153            .unwrap();
4154        assert_eq!(resp.status(), StatusCode::OK);
4155        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4156        let v: Value = serde_json::from_slice(&bytes).unwrap();
4157        assert_eq!(v["removed"], false);
4158    }
4159
4160    #[tokio::test]
4161    async fn serves_index_html_fallback() {
4162        let state = test_state();
4163        let app = router().with_state(state);
4164        let resp = app
4165            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4166            .await
4167            .unwrap();
4168        // Either OK with embedded HTML, or NOT_FOUND if assets not built.
4169        assert!(
4170            resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
4171            "got {}",
4172            resp.status()
4173        );
4174    }
4175
4176    /// Why (issue #96): `GET /api/v1/activity` must return the entries
4177    /// captured by the persistent log so the dashboard feed has history on
4178    /// page load. This drives the endpoint with a sequence of emits that
4179    /// model both HTTP- and MCP-origin writes, then asserts the response
4180    /// shape, ordering, total count, and that the source labels make it
4181    /// onto the wire.
4182    /// What: emits four `DaemonEvent`s with mixed sources, fetches
4183    /// `/api/v1/activity?limit=10`, and checks the structure of the
4184    /// returned JSON.
4185    /// Test: this test.
4186    #[tokio::test]
4187    async fn activity_endpoint_lists_recent_emits() {
4188        let state = test_state();
4189        // Three drawer_added (one MCP, two HTTP) and one palace_created.
4190        state.emit(DaemonEvent::PalaceCreated {
4191            id: "alpha".into(),
4192            name: "alpha".into(),
4193            source: ActivitySource::Http,
4194        });
4195        state.emit(DaemonEvent::DrawerAdded {
4196            palace_id: "alpha".into(),
4197            palace_name: "alpha".into(),
4198            drawer_count: 1,
4199            timestamp: chrono::Utc::now(),
4200            content_preview: "hello".into(),
4201            source: ActivitySource::Mcp,
4202        });
4203        state.emit(DaemonEvent::DrawerAdded {
4204            palace_id: "beta".into(),
4205            palace_name: "beta".into(),
4206            drawer_count: 1,
4207            timestamp: chrono::Utc::now(),
4208            content_preview: "hi there".into(),
4209            source: ActivitySource::Http,
4210        });
4211        state.emit(DaemonEvent::DrawerDeleted {
4212            palace_id: "alpha".into(),
4213            drawer_count: 0,
4214            source: ActivitySource::Http,
4215        });
4216        // Issue #232: emits now fire-and-forget the redb write on the
4217        // blocking pool; wait for the writes to settle before querying the
4218        // activity endpoint.
4219        state.flush_activity_writes().await;
4220
4221        let app = router().with_state(state);
4222        let resp = app
4223            .oneshot(
4224                Request::builder()
4225                    .uri("/api/v1/activity?limit=10")
4226                    .body(Body::empty())
4227                    .unwrap(),
4228            )
4229            .await
4230            .unwrap();
4231        assert_eq!(resp.status(), StatusCode::OK);
4232        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4233        let v: Value = serde_json::from_slice(&bytes).unwrap();
4234        assert_eq!(v["limit"], 10);
4235        assert_eq!(v["offset"], 0);
4236        assert_eq!(v["total"], 4);
4237        let entries = v["entries"].as_array().expect("entries array");
4238        assert_eq!(entries.len(), 4);
4239        // Newest-first: drawer_deleted is the last event we pushed.
4240        assert_eq!(entries[0]["event_type"], "drawer_deleted");
4241        assert_eq!(entries[3]["event_type"], "palace_created");
4242        // Sources made it onto the wire as lowercase strings.
4243        let sources: Vec<&str> = entries
4244            .iter()
4245            .filter_map(|e| e["source"].as_str())
4246            .collect();
4247        assert!(sources.contains(&"http"));
4248        assert!(sources.contains(&"mcp"));
4249        // Payload is structured JSON, not an escaped string.
4250        assert!(entries[0]["payload"].is_object());
4251    }
4252
4253    /// Why: the handler must enforce a sane upper bound on `limit` so a
4254    /// curl with `?limit=1000000` cannot force a huge scan + response.
4255    /// What: asks for `limit=10000`, asserts the response advertises the
4256    /// clamped value.
4257    /// Test: this test.
4258    #[tokio::test]
4259    async fn activity_endpoint_clamps_limit() {
4260        let state = test_state();
4261        let app = router().with_state(state);
4262        let resp = app
4263            .oneshot(
4264                Request::builder()
4265                    .uri("/api/v1/activity?limit=10000")
4266                    .body(Body::empty())
4267                    .unwrap(),
4268            )
4269            .await
4270            .unwrap();
4271        assert_eq!(resp.status(), StatusCode::OK);
4272        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4273        let v: Value = serde_json::from_slice(&bytes).unwrap();
4274        assert_eq!(v["limit"], ACTIVITY_MAX_LIMIT);
4275    }
4276
4277    /// Why: filters are how the dashboard scopes the feed to a single
4278    /// palace or to one origin (MCP vs HTTP). Confirm AND-semantics on
4279    /// `?palace=` and `?source=`.
4280    /// What: emits 3 events, queries with `?palace=alpha&source=mcp`, and
4281    /// asserts only the matching row is returned.
4282    /// Test: this test.
4283    #[tokio::test]
4284    async fn activity_endpoint_filters_by_source_and_palace() {
4285        let state = test_state();
4286        state.emit(DaemonEvent::DrawerAdded {
4287            palace_id: "alpha".into(),
4288            palace_name: "alpha".into(),
4289            drawer_count: 1,
4290            timestamp: chrono::Utc::now(),
4291            content_preview: "".into(),
4292            source: ActivitySource::Mcp,
4293        });
4294        state.emit(DaemonEvent::DrawerAdded {
4295            palace_id: "alpha".into(),
4296            palace_name: "alpha".into(),
4297            drawer_count: 2,
4298            timestamp: chrono::Utc::now(),
4299            content_preview: "".into(),
4300            source: ActivitySource::Http,
4301        });
4302        state.emit(DaemonEvent::DrawerAdded {
4303            palace_id: "beta".into(),
4304            palace_name: "beta".into(),
4305            drawer_count: 1,
4306            timestamp: chrono::Utc::now(),
4307            content_preview: "".into(),
4308            source: ActivitySource::Mcp,
4309        });
4310        // Issue #232: drain the spawn_blocking writes before querying.
4311        state.flush_activity_writes().await;
4312
4313        let app = router().with_state(state);
4314        let resp = app
4315            .oneshot(
4316                Request::builder()
4317                    .uri("/api/v1/activity?palace=alpha&source=mcp&limit=50")
4318                    .body(Body::empty())
4319                    .unwrap(),
4320            )
4321            .await
4322            .unwrap();
4323        assert_eq!(resp.status(), StatusCode::OK);
4324        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4325        let v: Value = serde_json::from_slice(&bytes).unwrap();
4326        let entries = v["entries"].as_array().unwrap();
4327        assert_eq!(entries.len(), 1, "filter should leave one row, got {v}");
4328        assert_eq!(entries[0]["palace_id"], "alpha");
4329        assert_eq!(entries[0]["source"], "mcp");
4330    }
4331
4332    /// Why: unknown source values must produce a 400 so the caller sees the
4333    /// typo instead of silently getting "no rows".
4334    #[tokio::test]
4335    async fn activity_endpoint_rejects_unknown_source() {
4336        let state = test_state();
4337        let app = router().with_state(state);
4338        let resp = app
4339            .oneshot(
4340                Request::builder()
4341                    .uri("/api/v1/activity?source=nope")
4342                    .body(Body::empty())
4343                    .unwrap(),
4344            )
4345            .await
4346            .unwrap();
4347        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4348    }
4349
4350    /// Why (issue #96): MCP-side `memory_remember` must now emit a
4351    /// `DrawerAdded` event with `source = Mcp`. Confirm by driving the MCP
4352    /// dispatcher directly and reading the broadcast channel.
4353    /// What: pre-creates a palace, calls `dispatch_tool("memory_remember",
4354    /// ...)`, subscribes to the events channel before the call, and
4355    /// asserts the next event tag is `drawer_added` with the MCP source.
4356    /// Test: this test.
4357    #[tokio::test]
4358    async fn mcp_memory_remember_emits_drawer_added_with_mcp_source() {
4359        use crate::tools::dispatch_tool;
4360        let state = test_state();
4361        let mut rx = state.events.subscribe();
4362        // Create palace via the MCP tool so the activity log captures both
4363        // the palace_created and drawer_added events.
4364        let _ = dispatch_tool(&state, "palace_create", json!({"name": "p1"}))
4365            .await
4366            .expect("palace_create");
4367        // Drain the palace_created event.
4368        let first = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4369            .await
4370            .expect("first event")
4371            .expect("channel open");
4372        assert!(
4373            matches!(first, DaemonEvent::PalaceCreated { ref source, .. } if *source == ActivitySource::Mcp)
4374        );
4375
4376        let _ = dispatch_tool(
4377            &state,
4378            "memory_remember",
4379            json!({
4380                "palace": "p1",
4381                "text": "the quick brown fox jumps over the lazy dog and more"
4382            }),
4383        )
4384        .await
4385        .expect("memory_remember");
4386
4387        // The next event from the channel should be DrawerAdded(Mcp).
4388        let next = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4389            .await
4390            .expect("drawer_added event")
4391            .expect("channel open");
4392        match next {
4393            DaemonEvent::DrawerAdded {
4394                source, palace_id, ..
4395            } => {
4396                assert_eq!(source, ActivitySource::Mcp);
4397                assert_eq!(palace_id, "p1");
4398            }
4399            other => panic!("expected DrawerAdded, got {other:?}"),
4400        }
4401
4402        // The activity log should now hold ≥ 2 entries (palace_created +
4403        // drawer_added). Also confirm the HTTP endpoint surfaces them with
4404        // `mcp` sources.
4405        // Issue #232: drain fire-and-forget activity-log writes first.
4406        state.flush_activity_writes().await;
4407        let app = router().with_state(state);
4408        let resp = app
4409            .oneshot(
4410                Request::builder()
4411                    .uri("/api/v1/activity?source=mcp&limit=10")
4412                    .body(Body::empty())
4413                    .unwrap(),
4414            )
4415            .await
4416            .unwrap();
4417        assert_eq!(resp.status(), StatusCode::OK);
4418        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4419        let v: Value = serde_json::from_slice(&bytes).unwrap();
4420        let entries = v["entries"].as_array().unwrap();
4421        let event_types: std::collections::HashSet<&str> = entries
4422            .iter()
4423            .filter_map(|e| e["event_type"].as_str())
4424            .collect();
4425        assert!(event_types.contains("drawer_added"));
4426        assert!(event_types.contains("palace_created"));
4427    }
4428
4429    // -----------------------------------------------------------------
4430    // Submission-logging tests (Part A: hook activity, Part B: drawer
4431    // attribution).
4432    // -----------------------------------------------------------------
4433
4434    /// Why (submission-logging Part A): every hook firing must produce an
4435    /// activity-feed entry tagged `source=hook` so a normal Claude Code
4436    /// session that only triggers hooks no longer leaves the TUI feed
4437    /// empty. The simplest direct check is to POST to the hook ingestion
4438    /// endpoint and confirm the new entry shows up in `GET /api/v1/activity`.
4439    /// What: posts a `HookEventPayload` to `/api/v1/activity/hook`, then
4440    /// queries `/api/v1/activity?source=hook&limit=1` and asserts a row
4441    /// exists with the matching event_type and source.
4442    /// Test: itself.
4443    #[tokio::test]
4444    async fn hook_fired_activity_emit_smoke() {
4445        let state = test_state();
4446        let app = router().with_state(state.clone());
4447
4448        let payload = serde_json::json!({
4449            "palace_id": "alpha",
4450            "palace_name": "alpha",
4451            "hook_type": "UserPromptSubmit",
4452            "injection_kind": "prompt-context",
4453            "injection_length": 256,
4454            "trigger_prompt_excerpt": "test prompt",
4455            "duration_ms": 12,
4456        });
4457        let resp = app
4458            .oneshot(
4459                Request::builder()
4460                    .method("POST")
4461                    .uri("/api/v1/activity/hook")
4462                    .header("content-type", "application/json")
4463                    .body(Body::from(payload.to_string()))
4464                    .unwrap(),
4465            )
4466            .await
4467            .unwrap();
4468        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
4469        // Issue #232: the hook handler emits via the fire-and-forget
4470        // `spawn_blocking` path; wait for the write to settle before
4471        // reading the activity history endpoint.
4472        state.flush_activity_writes().await;
4473
4474        // Read it back through the activity history endpoint.
4475        let app = router().with_state(state);
4476        let resp = app
4477            .oneshot(
4478                Request::builder()
4479                    .uri("/api/v1/activity?source=hook&limit=10")
4480                    .body(Body::empty())
4481                    .unwrap(),
4482            )
4483            .await
4484            .unwrap();
4485        assert_eq!(resp.status(), StatusCode::OK);
4486        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4487        let v: Value = serde_json::from_slice(&bytes).unwrap();
4488        let entries = v["entries"].as_array().expect("entries array");
4489        assert!(
4490            !entries.is_empty(),
4491            "expected at least one hook activity row, got {entries:?}"
4492        );
4493        let first = &entries[0];
4494        assert_eq!(first["source"], "hook");
4495        assert_eq!(first["event_type"], "hook_fired");
4496        assert_eq!(first["palace_id"], "alpha");
4497        let body = &first["payload"];
4498        assert_eq!(body["hook_type"], "UserPromptSubmit");
4499        assert_eq!(body["injection_kind"], "prompt-context");
4500    }
4501
4502    /// Why (submission-logging Part B): an HTTP drawer write with no
4503    /// client-identifying header must still produce a drawer carrying a
4504    /// `creator:client=unknown-http-client` tag so operators can recognise
4505    /// "writer didn't self-identify" as distinct from "writer is known".
4506    /// What: creates a palace via the registry, POSTs a drawer with no
4507    /// `X-Trusty-Client-Name` header, lists the palace drawers, asserts
4508    /// the new drawer carries the four creator tags with the default
4509    /// client name and `source=http`.
4510    /// Test: itself.
4511    #[tokio::test]
4512    async fn drawer_creator_attribution_http_default() {
4513        let tmp = tempfile::tempdir().expect("tempdir");
4514        let root = tmp.path().to_path_buf();
4515        std::mem::forget(tmp);
4516        let state = AppState::new(root);
4517        let palace = trusty_common::memory_core::Palace {
4518            id: PalaceId::new("cred-default"),
4519            name: "cred-default".to_string(),
4520            description: None,
4521            created_at: chrono::Utc::now(),
4522            data_dir: state.data_root.join("cred-default"),
4523        };
4524        state
4525            .registry
4526            .create_palace(&state.data_root, palace)
4527            .expect("create palace");
4528
4529        let app = router().with_state(state.clone());
4530        let body = serde_json::json!({
4531            "content": "hello world from anonymous client",
4532            "tags": ["user-tag"],
4533        });
4534        let resp = app
4535            .oneshot(
4536                Request::builder()
4537                    .method("POST")
4538                    .uri("/api/v1/palaces/cred-default/drawers")
4539                    .header("content-type", "application/json")
4540                    .body(Body::from(body.to_string()))
4541                    .unwrap(),
4542            )
4543            .await
4544            .unwrap();
4545        assert_eq!(resp.status(), StatusCode::OK);
4546
4547        // Inspect the persisted drawer's tags.
4548        let app = router().with_state(state);
4549        let resp = app
4550            .oneshot(
4551                Request::builder()
4552                    .uri("/api/v1/palaces/cred-default/drawers?limit=10")
4553                    .body(Body::empty())
4554                    .unwrap(),
4555            )
4556            .await
4557            .unwrap();
4558        assert_eq!(resp.status(), StatusCode::OK);
4559        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4560        let v: Value = serde_json::from_slice(&bytes).unwrap();
4561        let drawers = v.as_array().expect("drawers array");
4562        assert_eq!(drawers.len(), 1, "expected one drawer, got {drawers:?}");
4563        let tags: Vec<&str> = drawers[0]["tags"]
4564            .as_array()
4565            .expect("tags array")
4566            .iter()
4567            .filter_map(|t| t.as_str())
4568            .collect();
4569        assert!(
4570            tags.contains(&"user-tag"),
4571            "user-supplied tag must survive; got {tags:?}"
4572        );
4573        assert!(
4574            tags.contains(&"creator:client=unknown-http-client"),
4575            "expected default client tag; got {tags:?}"
4576        );
4577        assert!(
4578            tags.contains(&"creator:source=http"),
4579            "expected http source tag; got {tags:?}"
4580        );
4581        assert!(
4582            tags.iter().any(|t| t.starts_with("creator:version=")),
4583            "expected creator:version tag; got {tags:?}"
4584        );
4585    }
4586
4587    /// Why (submission-logging Part B): when an HTTP client *does* set
4588    /// `X-Trusty-Client-Name`, the drawer must carry that exact name in
4589    /// its `creator:client=` tag so operators can trace which client wrote
4590    /// which drawer.
4591    /// What: POST with `X-Trusty-Client-Name: qa-curl` and assert the
4592    /// rendered tag matches.
4593    /// Test: itself.
4594    #[tokio::test]
4595    async fn drawer_creator_attribution_http_header() {
4596        let tmp = tempfile::tempdir().expect("tempdir");
4597        let root = tmp.path().to_path_buf();
4598        std::mem::forget(tmp);
4599        let state = AppState::new(root);
4600        let palace = trusty_common::memory_core::Palace {
4601            id: PalaceId::new("cred-header"),
4602            name: "cred-header".to_string(),
4603            description: None,
4604            created_at: chrono::Utc::now(),
4605            data_dir: state.data_root.join("cred-header"),
4606        };
4607        state
4608            .registry
4609            .create_palace(&state.data_root, palace)
4610            .expect("create palace");
4611
4612        let app = router().with_state(state.clone());
4613        let body = serde_json::json!({
4614            "content": "this is enough content to pass the signal/noise filter applied by remember",
4615            "tags": [],
4616        });
4617        let resp = app
4618            .oneshot(
4619                Request::builder()
4620                    .method("POST")
4621                    .uri("/api/v1/palaces/cred-header/drawers")
4622                    .header("content-type", "application/json")
4623                    .header("x-trusty-client-name", "qa-curl")
4624                    .header("x-trusty-client-cwd", "/tmp/qa")
4625                    .body(Body::from(body.to_string()))
4626                    .unwrap(),
4627            )
4628            .await
4629            .unwrap();
4630        assert_eq!(resp.status(), StatusCode::OK);
4631
4632        let app = router().with_state(state);
4633        let resp = app
4634            .oneshot(
4635                Request::builder()
4636                    .uri("/api/v1/palaces/cred-header/drawers?limit=10")
4637                    .body(Body::empty())
4638                    .unwrap(),
4639            )
4640            .await
4641            .unwrap();
4642        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4643        let v: Value = serde_json::from_slice(&bytes).unwrap();
4644        let tags: Vec<&str> = v[0]["tags"]
4645            .as_array()
4646            .expect("tags")
4647            .iter()
4648            .filter_map(|t| t.as_str())
4649            .collect();
4650        assert!(
4651            tags.contains(&"creator:client=qa-curl"),
4652            "expected custom client tag; got {tags:?}"
4653        );
4654        assert!(
4655            tags.contains(&"creator:cwd=/tmp/qa"),
4656            "expected cwd tag from header; got {tags:?}"
4657        );
4658    }
4659
4660    /// Why (submission-logging Part B): drawers written through the MCP
4661    /// tool surface (`memory_remember`) must carry
4662    /// `creator:client=trusty-memory-mcp` and `creator:source=mcp` so
4663    /// operators can tell MCP-origin drawers apart from HTTP / CLI writes.
4664    /// What: dispatches `memory_remember` directly against an in-process
4665    /// `AppState` (no HTTP), then lists the palace drawers and asserts
4666    /// the MCP attribution tags landed.
4667    /// Test: itself.
4668    #[tokio::test]
4669    async fn drawer_creator_attribution_mcp_default() {
4670        let tmp = tempfile::tempdir().expect("tempdir");
4671        let root = tmp.path().to_path_buf();
4672        std::mem::forget(tmp);
4673        let state = AppState::new(root);
4674        let palace = trusty_common::memory_core::Palace {
4675            id: PalaceId::new("cred-mcp"),
4676            name: "cred-mcp".to_string(),
4677            description: None,
4678            created_at: chrono::Utc::now(),
4679            data_dir: state.data_root.join("cred-mcp"),
4680        };
4681        state
4682            .registry
4683            .create_palace(&state.data_root, palace)
4684            .expect("create palace");
4685
4686        let _ = crate::tools::dispatch_tool(
4687            &state,
4688            "memory_remember",
4689            json!({
4690                "palace": "cred-mcp",
4691                "text": "remember a sentence with enough tokens to pass filters please",
4692                "room": "General",
4693                "tags": ["from-test"],
4694            }),
4695        )
4696        .await
4697        .expect("memory_remember dispatch");
4698
4699        let app = router().with_state(state);
4700        let resp = app
4701            .oneshot(
4702                Request::builder()
4703                    .uri("/api/v1/palaces/cred-mcp/drawers?limit=10")
4704                    .body(Body::empty())
4705                    .unwrap(),
4706            )
4707            .await
4708            .unwrap();
4709        let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4710        let v: Value = serde_json::from_slice(&bytes).unwrap();
4711        let drawers = v.as_array().expect("drawers array");
4712        assert!(!drawers.is_empty(), "expected at least one drawer");
4713        let tags: Vec<&str> = drawers[0]["tags"]
4714            .as_array()
4715            .expect("tags array")
4716            .iter()
4717            .filter_map(|t| t.as_str())
4718            .collect();
4719        assert!(
4720            tags.contains(&"creator:client=trusty-memory-mcp"),
4721            "expected MCP client tag; got {tags:?}"
4722        );
4723        assert!(
4724            tags.contains(&"creator:source=mcp"),
4725            "expected MCP source tag; got {tags:?}"
4726        );
4727    }
4728
4729    /// Why (submission-logging Part A, failure isolation): if the daemon
4730    /// is unreachable when the hook fires, the hook command MUST still
4731    /// return `Ok(())` so the user's prompt is not blocked. The activity
4732    /// emit failure is surfaced via a stderr warn-log only.
4733    /// What: pins a tempdir as the data dir (so `read_daemon_addr`
4734    /// returns `Ok(None)` — no http_addr file), runs `handle_prompt_context`,
4735    /// and asserts it returns `Ok(())`. Separately verifies the emit
4736    /// helper does not panic — covered by `post_hook_event_no_daemon_is_noop`
4737    /// in `hook_emit::tests`.
4738    /// Test: itself.
4739    #[tokio::test]
4740    async fn hook_emit_failure_isolated() {
4741        let _guard = crate::commands::env_test_lock().lock().await;
4742        let tmp = tempfile::tempdir().expect("tempdir");
4743        // SAFETY: test serialised via env_test_lock.
4744        unsafe {
4745            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
4746        }
4747        let res = crate::commands::prompt_context::handle_prompt_context().await;
4748        unsafe {
4749            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
4750        }
4751        assert!(
4752            res.is_ok(),
4753            "hook must complete even when daemon emit fails; got {res:?}"
4754        );
4755    }
4756
4757    /// Why: The base64url triple-ID round-trip is the core invariant for
4758    /// `DELETE /kg/triples/<id>` — if encode/decode aren't inverses, the
4759    /// handler will always 404 on valid IDs.
4760    /// What: Encodes a (subject, predicate) pair, decodes the result, and
4761    /// asserts exact equality with the originals. Also tests the null-byte
4762    /// separator and URL-safety.
4763    /// Test: This test.
4764    #[test]
4765    fn decode_triple_id_round_trips() {
4766        let cases = [
4767            ("drawer:some-uuid", "has_tag"),
4768            ("entity:alice", "works_at"),
4769            ("entity:project/foo", "depends_on"),
4770            // edge: empty predicate
4771            ("subject", ""),
4772            // edge: subject with slashes + predicate with colons
4773            ("path/to/node", "rel:type:sub"),
4774        ];
4775        for (subject, predicate) in cases {
4776            let encoded = encode_triple_id(subject, predicate);
4777            // Must be URL-safe: no +, /, or = characters.
4778            assert!(
4779                !encoded.contains('+') && !encoded.contains('/') && !encoded.contains('='),
4780                "encoded triple id {encoded:?} is not URL-safe"
4781            );
4782            let (s, p) = decode_triple_id(&encoded)
4783                .unwrap_or_else(|| panic!("decode_triple_id failed for {encoded:?}"));
4784            assert_eq!(s, subject, "subject mismatch for ({subject}, {predicate})");
4785            assert_eq!(
4786                p, predicate,
4787                "predicate mismatch for ({subject}, {predicate})"
4788            );
4789        }
4790    }
4791
4792    /// Why: `decode_triple_id` must return `None` on garbage input (not panic).
4793    /// What: Passes invalid base64 and base64 without a null separator; asserts None.
4794    /// Test: This test.
4795    #[test]
4796    fn decode_triple_id_returns_none_for_invalid_input() {
4797        assert!(decode_triple_id("not!!valid%%base64").is_none());
4798        // Valid base64url but no null separator → no split possible.
4799        use base64::Engine as _;
4800        let no_sep = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"no-separator");
4801        assert!(decode_triple_id(&no_sep).is_none());
4802    }
4803}