Skip to main content

solo_api/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HTTP/JSON transport for Solo. Local-only by default — binds to
4//! `127.0.0.1:<port>` and serves the same operations the MCP server
5//! exposes:
6//!
7//! Episode operations:
8//!   - `POST /memory`                — remember (body: { content, source_type?, source_id? })
9//!   - `POST /memory/search`         — recall  (body: { query, limit? })
10//!   - `GET  /memory/{id}`           — inspect
11//!   - `DELETE /memory/{id}?reason=…` — forget
12//!
13//! Maintenance:
14//!   - `POST /memory/consolidate`    — trigger a consolidation pass
15//!   - `POST /backup`                — encrypted online backup
16//!
17//! Derived-layer (v0.4.0+; queries against the Steward's outputs):
18//!   - `GET  /memory/themes?window_days=N&limit=K`
19//!   - `GET  /memory/facts_about?subject=X&predicate=Y&since_ms=N&until_ms=N&include_as_object=B&limit=K`
20//!   - `GET  /memory/contradictions?limit=K`
21//!   - `GET  /memory/clusters/{cluster_id}?full_content=true` (v0.5.0+)
22//!
23//! Document operations (v0.7.0+):
24//!   - `POST   /memory/documents`               — ingest a file
25//!   - `POST   /memory/documents/search`        — vector search over chunks
26//!   - `GET    /memory/documents`               — paginate documents
27//!   - `GET    /memory/documents/{id}`          — inspect one document
28//!   - `DELETE /memory/documents/{id}`          — soft-delete a document
29//!
30//! There's no auth at this layer. The threat model is local-machine
31//! single-user; binding to `127.0.0.1` keeps the surface off the LAN.
32//! A future commit can add bearer-token auth + LAN binding.
33//!
34//! ## Lifecycle
35//!
36//! `serve_http(addr, server, shutdown)` binds to `addr`, runs axum with
37//! `with_graceful_shutdown(shutdown)`, returns when shutdown fires or
38//! the listener errors. `solo http-serve` invokes this from inside a
39//! `OneShotContext`, so writer + reader pool + lockfile stay live for
40//! the server's lifetime and clean up properly afterwards.
41
42use std::convert::Infallible;
43use std::net::SocketAddr;
44use std::str::FromStr;
45use std::sync::Arc;
46use std::time::Duration;
47
48use axum::extract::{FromRequestParts, Path, Query, State};
49use axum::http::request::Parts;
50use axum::http::{HeaderValue, Method, StatusCode};
51use axum::response::sse::{Event, KeepAlive, Sse};
52use axum::response::{IntoResponse, Response};
53use axum::routing::{get, post};
54use axum::{Json, Router};
55use futures::Stream;
56use serde::{Deserialize, Serialize};
57use solo_core::{
58    Confidence, DocumentId, EncodingContext, Episode, InvalidateEvent, MemoryId, TenantId,
59    Tier,
60};
61use solo_storage::{TenantHandle, TenantRegistry};
62use tokio::sync::broadcast;
63use tower_http::cors::{AllowOrigin, CorsLayer};
64use tower_http::trace::TraceLayer;
65
66use crate::auth::{AuthConfig, AuthenticatedPrincipal, middleware::AuthValidator};
67
68/// HTTP-side application state. v0.8.0 P2 swapped per-handler `WriteHandle
69/// + ReaderPool + ...` for a `TenantRegistry` that resolves tenant on each
70/// request via the `X-Solo-Tenant` header (default tenant if absent).
71#[derive(Clone)]
72pub struct SoloHttpState {
73    /// Multi-tenant registry. Lazy-loads tenants on first request.
74    pub registry: Arc<TenantRegistry>,
75    /// Default tenant used when the `X-Solo-Tenant` header is absent.
76    /// Typically `TenantId::default_tenant()`.
77    pub default_tenant: TenantId,
78    /// Read-path aliases for the canonical `"user"` subject. Sourced
79    /// from `solo.config.toml` `[identity] user_aliases`; threaded
80    /// through to `solo_query::facts_about` so a query for `"alex"`
81    /// also surfaces rows historically extracted as `"user"`. Empty
82    /// vec = behave as today. Wrapped in `Arc` so handler `clone()`s
83    /// stay cheap. v0.5.0 Priority 1 sub-step 1C.
84    pub user_aliases: Arc<Vec<String>>,
85}
86
87/// HTTP header that routes a request to a specific tenant. Optional;
88/// absent → state.default_tenant.
89pub const TENANT_HEADER: &str = "x-solo-tenant";
90
91/// Axum extractor that resolves the request's target tenant, then
92/// lazy-opens the tenant via the registry.
93///
94/// Resolution order (v0.8.0 P3):
95///   1. `AuthenticatedPrincipal.tenant_claim` from request extensions —
96///      set by the auth middleware. In OIDC mode this is the validated
97///      value of the configured custom claim (default `solo_tenant`);
98///      in bearer mode this is the daemon's default tenant.
99///   2. `X-Solo-Tenant` header — falls back to this when no
100///      authenticated principal is on the request (unauthenticated
101///      loopback deployments — the default).
102///   3. `state.default_tenant` when neither is present.
103///
104/// Bad header values → 400. Lazy-open failures → 500 unless the failure
105/// kind is `NotFound` (unknown tenant id) → 404.
106pub struct TenantExtractor(pub Arc<TenantHandle>);
107
108impl<S> FromRequestParts<S> for TenantExtractor
109where
110    SoloHttpState: FromRef<S>,
111    S: Send + Sync,
112{
113    type Rejection = ApiError;
114
115    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
116        let state = SoloHttpState::from_ref(state);
117        // Order: (1) principal.tenant_claim (set by auth middleware),
118        // (2) X-Solo-Tenant header, (3) state.default_tenant.
119        //
120        // The principal wins because in OIDC mode the JWT is the source
121        // of truth — letting the header override an OIDC claim would
122        // be a tenant-impersonation hole.
123        let resolved = if let Some(principal) = parts.extensions.get::<AuthenticatedPrincipal>()
124            && let Some(claim) = principal.tenant_claim.clone()
125        {
126            claim
127        } else {
128            match parts.headers.get(TENANT_HEADER) {
129                None => state.default_tenant.clone(),
130                Some(raw) => {
131                    let s = raw.to_str().map_err(|e| {
132                        ApiError::bad_request(format!(
133                            "{TENANT_HEADER}: header value must be ASCII ({e})"
134                        ))
135                    })?;
136                    TenantId::new(s.to_string()).map_err(|e| {
137                        ApiError::bad_request(format!("{TENANT_HEADER}: invalid tenant id: {e}"))
138                    })?
139                }
140            }
141        };
142        let handle = state.registry.get_or_open(&resolved).await.map_err(|e| {
143            // Map NotFound → 404; everything else → 500.
144            use solo_core::Error;
145            match &e {
146                Error::NotFound(_) => ApiError::not_found(e.to_string()),
147                Error::InvalidInput(_) => ApiError::bad_request(e.to_string()),
148                _ => ApiError::internal(e.to_string()),
149            }
150        })?;
151        Ok(TenantExtractor(handle))
152    }
153}
154
155use axum::extract::FromRef;
156
157/// v0.8.0 P4: extractor that pulls the authenticated principal's
158/// `subject` (JWT `sub` or `"bearer"`) out of request extensions for the
159/// audit log. `None` when no `AuthenticatedPrincipal` is present
160/// (unauthenticated loopback deployments).
161pub struct AuditPrincipal(pub Option<String>);
162
163impl<S> FromRequestParts<S> for AuditPrincipal
164where
165    S: Send + Sync,
166{
167    type Rejection = std::convert::Infallible;
168
169    async fn from_request_parts(
170        parts: &mut Parts,
171        _state: &S,
172    ) -> Result<Self, Self::Rejection> {
173        Ok(AuditPrincipal(
174            parts
175                .extensions
176                .get::<AuthenticatedPrincipal>()
177                .map(|p| p.subject.clone()),
178        ))
179    }
180}
181
182/// v0.10.0: extractor that lifts the full `AuthenticatedPrincipal` out
183/// of request extensions for the `/v1/tenants` handler. Distinct from
184/// `AuditPrincipal` (which only carries `subject: Option<String>`) — the
185/// tenant-list handler needs the `tenant_claim` and `claims` fields to
186/// distinguish bearer (claims = Null) from OIDC (claims = JWT object)
187/// principals.
188///
189/// `None` when no `AuthenticatedPrincipal` is on the request — the
190/// unauthenticated loopback deployment path, which the tenant-list
191/// handler treats as "all tenants visible" (same scope as the
192/// `solo tenants list` CLI). See `docs/dev-log/0119-tenants-list-impl.md`
193/// for the three-case visibility rule.
194pub struct MaybePrincipal(pub Option<AuthenticatedPrincipal>);
195
196impl<S> FromRequestParts<S> for MaybePrincipal
197where
198    S: Send + Sync,
199{
200    type Rejection = std::convert::Infallible;
201
202    async fn from_request_parts(
203        parts: &mut Parts,
204        _state: &S,
205    ) -> Result<Self, Self::Rejection> {
206        Ok(MaybePrincipal(
207            parts
208                .extensions
209                .get::<AuthenticatedPrincipal>()
210                .cloned(),
211        ))
212    }
213}
214
215/// Build the router with optional bearer-token auth (v0.7.x legacy shape).
216///
217/// When `bearer_token` is `Some(t)`, every request except `GET /health`
218/// + `GET /openapi.json` (unauthenticated probes / machine-readable spec)
219/// requires `Authorization: Bearer t`. v0.8.0 P3 routes this through the
220/// new `AuthValidator::Bearer` middleware so an `AuthenticatedPrincipal`
221/// is attached to every authenticated request (the `TenantExtractor`
222/// reads `principal.tenant_claim` ahead of the `X-Solo-Tenant` header).
223pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
224    let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
225    router_with_auth_config(state, auth)
226}
227
228/// Build the router with a config-driven auth block (v0.8.0 P3+).
229///
230/// `auth = Some(AuthConfig::Bearer { token })` is equivalent to passing
231/// `Some(token)` to [`router_with_auth`]. `auth = Some(AuthConfig::Oidc { … })`
232/// installs the OIDC middleware (JWKS fetch + cache + sig + claim checks).
233/// `auth = None` runs unauthenticated — same `127.0.0.1` default as v0.7.x.
234///
235/// Public routes (`/health`, `/openapi.json`) are always exempt from
236/// auth — load balancers, uptime monitors, and codegen tools shouldn't
237/// need credentials.
238pub fn router_with_auth_config(state: SoloHttpState, auth: Option<AuthConfig>) -> Router {
239    let cors = build_cors_layer();
240    // Public, always-unauthenticated routes:
241    //   - GET /health: liveness probe (load balancers, uptime monitors).
242    //   - GET /openapi.json: machine-readable API description for client
243    //     codegen + browser-UI tooling (TypeScript / OpenAPI Generator,
244    //     curl-tools, etc.). The spec describes the API shape, not
245    //     secrets — fine to serve unauthenticated even on a LAN-bound
246    //     instance.
247    let public = Router::new()
248        .route("/health", get(|| async { "ok" }))
249        .route("/openapi.json", get(openapi_handler));
250
251    let authed = Router::new()
252        .route("/memory", post(remember_handler))
253        .route("/memory/search", post(recall_handler))
254        .route("/memory/consolidate", post(consolidate_handler))
255        .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
256        .route("/backup", post(backup_handler))
257        // Path 1 derived-layer endpoints (v0.4.0+). GET-shaped because
258        // these are pure read-only queries; query-string params for
259        // simple filters keep them curl-friendly without a JSON body.
260        .route("/memory/themes", get(themes_handler))
261        .route("/memory/facts_about", get(facts_about_handler))
262        .route("/memory/contradictions", get(contradictions_handler))
263        // v0.5.0 Priority 3: drill into one cluster + abstraction +
264        // episodes. Two-segment path (`/memory/clusters/{id}`) so it
265        // does not shadow the single-segment `/memory/{id}` UUID
266        // inspect route.
267        .route(
268            "/memory/clusters/{cluster_id}",
269            get(inspect_cluster_handler),
270        )
271        // v0.7.0 P6: document operations. Two-segment paths
272        // (`/memory/documents/...`) so they don't shadow the
273        // single-segment `/memory/{id}` episode-inspect route. Order
274        // matters: register the literal `/memory/documents/search`
275        // ahead of `/memory/documents/{id}` so axum's matcher prefers
276        // the literal over the path parameter.
277        .route(
278            "/memory/documents/search",
279            post(search_docs_handler),
280        )
281        .route(
282            "/memory/documents",
283            post(ingest_document_handler).get(list_documents_handler),
284        )
285        .route(
286            "/memory/documents/{id}",
287            get(inspect_document_handler).delete(forget_document_handler),
288        )
289        // v0.9.x: graph drill-down for solo-web. Read-only neighbor
290        // expansion off any node in the memory graph. See
291        // `docs/dev-log/0105-solo-web-scoping.md` §4 + the impl dev log
292        // for the full `/v1/graph/*` family this is the first of.
293        .route("/v1/graph/expand", get(graph_expand_handler))
294        // v0.10.0: paginated catalog reads for solo-web's initial graph
295        // render. See `docs/dev-log/0114-graph-nodes-edges-impl.md`
296        // alongside the same scoping doc.
297        .route("/v1/graph/nodes", get(graph_nodes_handler))
298        .route("/v1/graph/edges", get(graph_edges_handler))
299        // v0.10.0: kind-discriminated full-record drill for solo-web's
300        // inspector panel. See `docs/dev-log/0115-graph-inspect-impl.md`.
301        .route("/v1/graph/inspect/{id}", get(graph_inspect_handler))
302        // v0.10.0: unified explicit + HNSW-semantic neighbors for solo-
303        // web's "show similar" overlay. See
304        // `docs/dev-log/0116-graph-neighbors-impl.md`.
305        .route("/v1/graph/neighbors/{id}", get(graph_neighbors_handler))
306        // v0.10.0: Server-Sent Events stream of graph-data invalidations
307        // for solo-web's live update story. The wire format is
308        // INVALIDATION-shaped (`{reason, tenant_id, ts_ms, kind}`) per
309        // scoping doc §3 Decision C — clients refetch the affected page
310        // on each event rather than receiving row payloads. See
311        // `docs/dev-log/0117-graph-stream-impl.md`.
312        .route("/v1/graph/stream", get(graph_stream_handler))
313        // v0.10.0: principal-scoped tenant list for solo-web's top-bar
314        // tenant picker. Read-only — admin CRUD (create/delete) remains
315        // CLI-only per ADR-0004 §"Admin operations". The visibility
316        // filter is principal-driven: no-auth + bearer principals see
317        // every active tenant; OIDC principals see only the tenant
318        // named by their `tenant_claim`. See
319        // `docs/dev-log/0119-tenants-list-impl.md` + scoping doc §3
320        // Decision F + §4 Route 6.
321        .route("/v1/tenants", get(tenants_list_handler))
322        .with_state(state.clone());
323
324    let authed = if let Some(cfg) = auth {
325        // v0.8.0 P3: dispatch via AuthValidator (bearer | OIDC), inserts
326        // AuthenticatedPrincipal into request extensions for the
327        // TenantExtractor + audit-log to read.
328        let validator = Arc::new(AuthValidator::from_config(
329            &cfg,
330            state.default_tenant.clone(),
331        ));
332        authed.layer(axum::middleware::from_fn_with_state(
333            validator,
334            crate::auth::middleware::auth_middleware,
335        ))
336    } else {
337        authed
338    };
339
340    public
341        .merge(authed)
342        .layer(cors)
343        .layer(TraceLayer::new_for_http())
344}
345
346/// Convenience wrapper: no auth (loopback-only deployments).
347pub fn router(state: SoloHttpState) -> Router {
348    router_with_auth_config(state, None)
349}
350
351fn build_cors_layer() -> CorsLayer {
352    // Permissive-localhost CORS: allow any localhost / 127.0.0.1 origin so
353    // browser-based UIs running on a different local port can call the API
354    // without preflight friction. We do NOT use `Any` because that would
355    // allow arbitrary remote origins to talk to our localhost server via
356    // a victim's browser. With bearer-token auth enabled the practical
357    // impact is reduced (the cross-origin attacker still can't supply
358    // the token), but principle of least privilege says refuse anyway.
359    //
360    // When the server is bound to a non-loopback address (auth required),
361    // the same CORS predicate keeps localhost-only browser clients —
362    // suitable for trusted-LAN deployments where the LAN client itself
363    // tunnels through ssh/wireguard back to localhost. Wider CORS for
364    // genuine cross-origin browser use is a future config knob.
365    CorsLayer::new()
366        .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
367            origin
368                .to_str()
369                .map(is_localhost_origin)
370                .unwrap_or(false)
371        }))
372        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
373        .allow_headers([
374            axum::http::header::CONTENT_TYPE,
375            axum::http::header::AUTHORIZATION,
376            // Custom Solo headers — browsers preflight-check these and
377            // refuse the actual request if they're not in the allow list.
378            // Without `x-solo-tenant` solo-web's browser fetches all fail
379            // with "Failed to fetch" (CORS preflight rejection).
380            axum::http::HeaderName::from_static("x-solo-tenant"),
381        ])
382}
383
384/// True if `origin` is `http(s)://localhost[:port]` or
385/// `http(s)://127.0.0.1[:port]` or `http(s)://[::1][:port]` (loopback IPv6).
386/// Anything else (incl. nip.io tricks like `127.0.0.1.nip.io`) is rejected.
387fn is_localhost_origin(origin: &str) -> bool {
388    let rest = origin
389        .strip_prefix("http://")
390        .or_else(|| origin.strip_prefix("https://"));
391    let host = match rest {
392        Some(r) => r,
393        None => return false,
394    };
395    // Strip path (shouldn't appear on Origin headers but defend anyway).
396    let host = host.split('/').next().unwrap_or(host);
397    // Strip port.
398    let host = if let Some(idx) = host.rfind(':') {
399        // For [::1]:port, keep the brackets in the host part.
400        if host.starts_with('[') {
401            // Find matching ']'; everything up to and including it is the host.
402            host.find(']')
403                .map(|i| &host[..=i])
404                .unwrap_or(host)
405        } else {
406            &host[..idx]
407        }
408    } else {
409        host
410    };
411    matches!(host, "localhost" | "127.0.0.1" | "[::1]")
412}
413
414/// Bind + serve (v0.7.x legacy shape). `shutdown` is awaited inside
415/// axum's `with_graceful_shutdown`; resolving it triggers a clean drain.
416/// `bearer_token = None` runs unauthenticated (loopback default);
417/// `Some(t)` requires `Authorization: Bearer t` on every request
418/// except `GET /health` + `GET /openapi.json`.
419pub async fn serve_http(
420    addr: SocketAddr,
421    state: SoloHttpState,
422    bearer_token: Option<String>,
423    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
424) -> std::io::Result<()> {
425    let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
426    serve_http_with_auth_config(addr, state, auth, shutdown).await
427}
428
429/// Bind + serve with a config-driven auth block (v0.8.0 P3+).
430/// `auth = None` runs unauthenticated. See [`router_with_auth_config`]
431/// for the auth-mode semantics.
432pub async fn serve_http_with_auth_config(
433    addr: SocketAddr,
434    state: SoloHttpState,
435    auth: Option<AuthConfig>,
436    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
437) -> std::io::Result<()> {
438    let auth_kind = match &auth {
439        Some(AuthConfig::Bearer { .. }) => "bearer",
440        Some(AuthConfig::Oidc { .. }) => "oidc",
441        None => "none",
442    };
443    let app = router_with_auth_config(state, auth);
444    let listener = tokio::net::TcpListener::bind(addr).await?;
445    tracing::info!(%addr, auth = auth_kind, "solo http: listening");
446    axum::serve(listener, app)
447        .with_graceful_shutdown(shutdown)
448        .await
449}
450
451// ---------------------------------------------------------------------------
452// OpenAPI 3.1 spec
453// ---------------------------------------------------------------------------
454
455/// Serve the hand-crafted OpenAPI 3.1 spec at `GET /openapi.json`.
456///
457/// We keep the spec hand-written (rather than deriving via `utoipa`)
458/// for v0.1: 4 simple endpoints, types live across crate boundaries
459/// (`solo_query::RecallResult`, `solo_query::EpisodeRecord`), and a
460/// `utoipa` retrofit would touch every crate. Hand-crafted is one
461/// JSON literal in this file; a smoke test in `handler_tests` parses
462/// the response and asserts the expected paths + components are
463/// present, so drift between spec and code is caught at PR time.
464async fn openapi_handler() -> Json<serde_json::Value> {
465    Json(openapi_spec())
466}
467
468/// Build the OpenAPI 3.1 spec describing Solo's HTTP transport.
469/// Public so the smoke test + future client-codegen tooling can
470/// produce the same document without spinning up the server.
471pub fn openapi_spec() -> serde_json::Value {
472    serde_json::json!({
473        "openapi": "3.1.0",
474        "info": {
475            "title": "Solo HTTP API",
476            "description":
477                "Local-first personal memory daemon. The HTTP transport \
478                 mirrors the four MCP tools (memory_remember / recall / \
479                 inspect / forget). Default deployment is loopback-only \
480                 (127.0.0.1); LAN-bound deployments require a bearer \
481                 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
482            "version": env!("CARGO_PKG_VERSION"),
483            "license": { "name": "Apache-2.0" }
484        },
485        "servers": [
486            { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
487        ],
488        "components": {
489            "securitySchemes": {
490                "bearerAuth": {
491                    "type": "http",
492                    "scheme": "bearer",
493                    "description":
494                        "Bearer-token auth. Required only on LAN-bound deployments \
495                         (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
496                         the default `127.0.0.1` deployment is unauthenticated. \
497                         `GET /health` and `GET /openapi.json` are exempt from auth even \
498                         on bearer-protected instances."
499                }
500            },
501            "schemas": {
502                "RememberRequest": {
503                    "type": "object",
504                    "required": ["content"],
505                    "properties": {
506                        "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
507                        "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
508                        "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
509                    },
510                    "additionalProperties": false
511                },
512                "RememberResponse": {
513                    "type": "object",
514                    "required": ["memory_id"],
515                    "properties": {
516                        "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
517                    }
518                },
519                "RecallRequest": {
520                    "type": "object",
521                    "required": ["query"],
522                    "properties": {
523                        "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
524                        "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
525                    },
526                    "additionalProperties": false
527                },
528                "RecallResult": {
529                    "type": "object",
530                    "description":
531                        "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
532                         see `solo_query::RecallResult` in the source for the canonical shape. \
533                         Treat as a forward-compatible JSON object.",
534                    "additionalProperties": true
535                },
536                "ConsolidationScope": {
537                    "type": "object",
538                    "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
539                    "properties": {
540                        "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
541                        "force_merge": { "type": "boolean", "default": false, "description": "Run the existing-vs-existing merge + abstraction-regen passes even with zero unclustered candidates. Drift catch-up on quiet corpora. Added in 0.3.1." }
542                    },
543                    "additionalProperties": false
544                },
545                "ConsolidationReport": {
546                    "type": "object",
547                    "required": [
548                        "episodes_seen", "clusters_built", "clusters_merged",
549                        "clusters_absorbed", "existing_clusters_merged",
550                        "episodes_clustered", "abstractions_built",
551                        "abstractions_regenerated", "triples_built",
552                        "contradictions_found"
553                    ],
554                    "properties": {
555                        "episodes_seen":             { "type": "integer", "minimum": 0 },
556                        "clusters_built":            { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
557                        "clusters_merged":           { "type": "integer", "minimum": 0, "description": "In-run merge: clusters absorbed into a sibling within this consolidate run (cross-UTC-bucket case). Counts losers." },
558                        "clusters_absorbed":         { "type": "integer", "minimum": 0, "description": "Cross-run absorb: freshly-built clusters folded into a pre-existing DB cluster with a similar centroid. Counts new-side clusters." },
559                        "existing_clusters_merged":  { "type": "integer", "minimum": 0, "description": "Existing-vs-existing merge: pre-existing DB clusters that drifted toward each other and now coalesce. Counts losers." },
560                        "episodes_clustered":        { "type": "integer", "minimum": 0 },
561                        "abstractions_built":        { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
562                        "abstractions_regenerated":  { "type": "integer", "minimum": 0, "description": "Existing clusters whose stale abstractions were dropped and rebuilt because absorb or existing-merge changed their episode set. 0 without an LlmClient." },
563                        "triples_built":             { "type": "integer", "minimum": 0 },
564                        "contradictions_found":      { "type": "integer", "minimum": 0 }
565                    }
566                },
567                "EpisodeRecord": {
568                    "type": "object",
569                    "description":
570                        "Inspect response: full episode record. Fields are stable across v0.1 but not \
571                         exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
572                         Treat as a forward-compatible JSON object.",
573                    "additionalProperties": true
574                },
575                "ThemeHit": {
576                    "type": "object",
577                    "description":
578                        "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
579                         See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
580                         abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
581                    "additionalProperties": true
582                },
583                "FactHit": {
584                    "type": "object",
585                    "description":
586                        "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
587                         See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
588                         object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
589                    "additionalProperties": true
590                },
591                "ContradictionHit": {
592                    "type": "object",
593                    "description":
594                        "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
595                         Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
596                         a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
597                    "additionalProperties": true
598                },
599                "ClusterRecord": {
600                    "type": "object",
601                    "description":
602                        "Snapshot of one cluster — its row, optional abstraction, and source episodes \
603                         (content truncated to 200 chars unless ?full_content=true). Returned by \
604                         GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
605                    "additionalProperties": true
606                },
607                "IngestDocumentRequest": {
608                    "type": "object",
609                    "required": ["path"],
610                    "properties": {
611                        "path": {
612                            "type": "string",
613                            "minLength": 1,
614                            "description":
615                                "Server-side absolute path to the file to ingest. The file must be \
616                                 readable by the Solo process. Supported formats: plaintext / \
617                                 markdown / code, HTML, PDF."
618                        }
619                    },
620                    "additionalProperties": false
621                },
622                "IngestReport": {
623                    "type": "object",
624                    "description":
625                        "Returned by POST /memory/documents. Reports the document id assigned, \
626                         the number of chunks persisted + embedded, the total byte size, and a \
627                         `deduped` flag (true when the same content_hash was already present and \
628                         the existing doc_id was returned unchanged). See `solo_storage::IngestReport`.",
629                    "required": ["doc_id", "chunks_persisted", "bytes_ingested", "deduped"],
630                    "properties": {
631                        "doc_id":            { "type": "string", "format": "uuid" },
632                        "chunks_persisted":  { "type": "integer", "minimum": 0 },
633                        "bytes_ingested":    { "type": "integer", "minimum": 0, "format": "int64" },
634                        "deduped":           { "type": "boolean" }
635                    },
636                    "additionalProperties": false
637                },
638                "ForgetDocumentReport": {
639                    "type": "object",
640                    "description":
641                        "Returned by DELETE /memory/documents/{id}. Reports the doc_id soft-deleted \
642                         and how many chunk rowids were tombstoned in the HNSW index. The chunk rows \
643                         themselves survive in SQL for forensic value. See `solo_storage::ForgetDocumentReport`.",
644                    "required": ["doc_id", "chunks_tombstoned"],
645                    "properties": {
646                        "doc_id":             { "type": "string", "format": "uuid" },
647                        "chunks_tombstoned":  { "type": "integer", "minimum": 0 }
648                    },
649                    "additionalProperties": false
650                },
651                "SearchDocsRequest": {
652                    "type": "object",
653                    "required": ["query"],
654                    "properties": {
655                        "query": { "type": "string", "minLength": 1 },
656                        "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }
657                    },
658                    "additionalProperties": false
659                },
660                "DocSearchHit": {
661                    "type": "object",
662                    "description":
663                        "One chunk hit + parent-doc context. Fields per `solo_query::DocSearchHit`: \
664                         chunk_id, doc_id, doc_title?, doc_source?, doc_mime_type?, chunk_index, \
665                         content, cos_distance, start_offset, end_offset.",
666                    "additionalProperties": true
667                },
668                "DocumentInspectResult": {
669                    "type": "object",
670                    "description":
671                        "Returned by GET /memory/documents/{id}. A `document` record (full metadata) \
672                         plus an ordered list of chunk summaries (each preview truncated to 200 \
673                         chars). See `solo_query::DocumentInspectResult`.",
674                    "additionalProperties": true
675                },
676                "DocumentSummary": {
677                    "type": "object",
678                    "description":
679                        "One row from GET /memory/documents. Fields per `solo_query::DocumentSummary`: \
680                         doc_id, title?, source?, mime_type?, ingested_at_ms, chunk_count, status.",
681                    "additionalProperties": true
682                },
683                "ApiError": {
684                    "type": "object",
685                    "required": ["error", "status"],
686                    "properties": {
687                        "error": { "type": "string" },
688                        "status": { "type": "integer", "minimum": 400, "maximum": 599 }
689                    }
690                }
691            }
692        },
693        "paths": {
694            "/health": {
695                "get": {
696                    "summary": "Liveness probe",
697                    "description": "Returns plain text `ok`. Always unauthenticated.",
698                    "responses": {
699                        "200": {
700                            "description": "Server is up.",
701                            "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
702                        }
703                    }
704                }
705            },
706            "/openapi.json": {
707                "get": {
708                    "summary": "Self-describing OpenAPI 3.1 spec",
709                    "description": "Returns this document. Always unauthenticated.",
710                    "responses": {
711                        "200": {
712                            "description": "OpenAPI 3.1 document.",
713                            "content": { "application/json": { "schema": { "type": "object" } } }
714                        }
715                    }
716                }
717            },
718            "/memory": {
719                "post": {
720                    "summary": "Remember (store an episode)",
721                    "description": "Equivalent to MCP tool `memory_remember`.",
722                    "security": [{ "bearerAuth": [] }, {}],
723                    "requestBody": {
724                        "required": true,
725                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
726                    },
727                    "responses": {
728                        "200": {
729                            "description": "Memory stored; returns the new MemoryId.",
730                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
731                        },
732                        "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
733                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
734                    }
735                }
736            },
737            "/memory/search": {
738                "post": {
739                    "summary": "Recall (vector search)",
740                    "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
741                    "security": [{ "bearerAuth": [] }, {}],
742                    "requestBody": {
743                        "required": true,
744                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
745                    },
746                    "responses": {
747                        "200": {
748                            "description": "Search results.",
749                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
750                        },
751                        "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
752                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
753                    }
754                }
755            },
756            "/memory/consolidate": {
757                "post": {
758                    "summary": "Run a consolidation pass (clustering + abstraction)",
759                    "description":
760                        "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
761                         on the server, also runs the REM-equivalent abstraction pass that populates \
762                         `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
763                         window). Equivalent to the `solo consolidate` CLI.",
764                    "security": [{ "bearerAuth": [] }, {}],
765                    "requestBody": {
766                        "required": false,
767                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
768                    },
769                    "responses": {
770                        "200": {
771                            "description": "Consolidation complete; report counts the work done.",
772                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
773                        },
774                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
775                    }
776                }
777            },
778            "/backup": {
779                "post": {
780                    "summary": "Online encrypted backup",
781                    "description":
782                        "Run an online SQLCipher backup of the live data dir to a server-side path. \
783                         The destination file is encrypted with the same Argon2id-derived raw key as \
784                         the source, so it restores under the same passphrase + a copy of the source's \
785                         `solo.config.toml`. Hot — the backup runs against the writer's existing \
786                         connection without taking the lockfile, so the daemon keeps serving reads + \
787                         writes during the operation. v0.3.2+.",
788                    "security": [{ "bearerAuth": [] }, {}],
789                    "requestBody": {
790                        "required": true,
791                        "content": { "application/json": { "schema": {
792                            "type": "object",
793                            "properties": {
794                                "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
795                                "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
796                            },
797                            "required": ["to"]
798                        } } }
799                    },
800                    "responses": {
801                        "200": {
802                            "description": "Backup complete; reports the destination path + elapsed milliseconds.",
803                            "content": { "application/json": { "schema": {
804                                "type": "object",
805                                "properties": {
806                                    "path": { "type": "string" },
807                                    "elapsed_ms": { "type": "integer", "format": "int64" }
808                                }
809                            } } }
810                        },
811                        "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
812                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
813                        "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
814                    }
815                }
816            },
817            "/memory/{id}": {
818                "get": {
819                    "summary": "Inspect a memory by ID",
820                    "description": "Equivalent to MCP tool `memory_inspect`.",
821                    "security": [{ "bearerAuth": [] }, {}],
822                    "parameters": [{
823                        "name": "id",
824                        "in": "path",
825                        "required": true,
826                        "schema": { "type": "string", "format": "uuid" },
827                        "description": "MemoryId (UUID v7)."
828                    }],
829                    "responses": {
830                        "200": {
831                            "description": "Episode record.",
832                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
833                        },
834                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
835                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
836                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
837                    }
838                },
839                "delete": {
840                    "summary": "Forget (soft-delete) a memory by ID",
841                    "description":
842                        "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
843                         and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
844                         re-running `solo reembed` after this does NOT restore visibility.",
845                    "security": [{ "bearerAuth": [] }, {}],
846                    "parameters": [
847                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
848                        { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
849                    ],
850                    "responses": {
851                        "204": { "description": "Forgotten (or already forgotten — idempotent)." },
852                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
853                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
854                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
855                    }
856                }
857            },
858            "/memory/themes": {
859                "get": {
860                    "summary": "List recent cluster themes",
861                    "description":
862                        "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
863                         most-recent first. Use to surface 'what has the user been thinking about lately' \
864                         without paging through individual episodes. v0.4.0+.",
865                    "security": [{ "bearerAuth": [] }, {}],
866                    "parameters": [
867                        { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
868                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
869                    ],
870                    "responses": {
871                        "200": {
872                            "description": "Array of ThemeHits (possibly empty).",
873                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
874                        },
875                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
876                    }
877                }
878            },
879            "/memory/facts_about": {
880                "get": {
881                    "summary": "Query the SPO knowledge graph by subject",
882                    "description":
883                        "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
884                         subject + optional predicate + optional time window. Subject is required \
885                         (predicate-only scans not supported). Pass `include_as_object=true` (v0.5.1+) \
886                         to also surface rows where `subject` appears as the object. v0.4.0+.",
887                    "security": [{ "bearerAuth": [] }, {}],
888                    "parameters": [
889                        { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
890                        { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
891                        { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
892                        { "name": "until_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through." },
893                        { "name": "include_as_object", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, also match rows where `subject` appears as the object (e.g. surface 'Sam pushes back on PRs about Maya' under subject='Maya'). Default false. v0.5.1+." },
894                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
895                    ],
896                    "responses": {
897                        "200": {
898                            "description": "Array of FactHits (possibly empty).",
899                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
900                        },
901                        "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
902                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
903                    }
904                }
905            },
906            "/memory/contradictions": {
907                "get": {
908                    "summary": "List Steward-flagged contradictions",
909                    "description":
910                        "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
911                         sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
912                    "security": [{ "bearerAuth": [] }, {}],
913                    "parameters": [
914                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
915                    ],
916                    "responses": {
917                        "200": {
918                            "description": "Array of ContradictionHits (possibly empty).",
919                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
920                        },
921                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
922                    }
923                }
924            },
925            "/memory/clusters/{cluster_id}": {
926                "get": {
927                    "summary": "Inspect a single cluster",
928                    "description":
929                        "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
930                         its (optional) abstraction, and its source episodes. By default each \
931                         episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
932                         `?full_content=true` to get verbatim episode content. v0.5.0+.",
933                    "security": [{ "bearerAuth": [] }, {}],
934                    "parameters": [
935                        { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
936                        { "name": "full_content", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, return episode content verbatim. Default false (truncate to 200 chars + ellipsis)." }
937                    ],
938                    "responses": {
939                        "200": {
940                            "description": "Cluster snapshot.",
941                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
942                        },
943                        "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
944                        "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
945                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
946                    }
947                }
948            },
949            "/memory/documents": {
950                "post": {
951                    "summary": "Ingest a document",
952                    "description":
953                        "Equivalent to MCP tool `memory_ingest_document`. Reads the file at the \
954                         supplied server-side path, parses + chunks + embeds, and persists under \
955                         `documents` + `document_chunks`. Returns the new doc_id, chunk count, and \
956                         a `deduped` flag (true when an existing document with the same content_hash \
957                         was returned without re-embedding). v0.7.0+.",
958                    "security": [{ "bearerAuth": [] }, {}],
959                    "requestBody": {
960                        "required": true,
961                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestDocumentRequest" } } }
962                    },
963                    "responses": {
964                        "200": {
965                            "description": "Document ingested (or deduplicated).",
966                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestReport" } } }
967                        },
968                        "400": { "description": "Bad request (e.g. empty path, file unreadable, parse error).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
969                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
970                    }
971                },
972                "get": {
973                    "summary": "List ingested documents (paginated)",
974                    "description":
975                        "Equivalent to MCP tool `memory_list_documents`. Returns a paginated index, \
976                         newest first. Forgotten documents are hidden by default; pass \
977                         `?include_forgotten=true` to see them too. v0.7.0+.",
978                    "security": [{ "bearerAuth": [] }, {}],
979                    "parameters": [
980                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
981                        { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
982                        { "name": "include_forgotten", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
983                    ],
984                    "responses": {
985                        "200": {
986                            "description": "Array of DocumentSummary (possibly empty).",
987                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentSummary" } } } }
988                        },
989                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
990                    }
991                }
992            },
993            "/memory/documents/search": {
994                "post": {
995                    "summary": "Vector search across document chunks",
996                    "description":
997                        "Equivalent to MCP tool `memory_search_docs`. Embeds the query and returns \
998                         up to `limit` matching chunks, best match first, each annotated with the \
999                         parent document's title + source path. Forgotten documents are excluded. \
1000                         v0.7.0+.",
1001                    "security": [{ "bearerAuth": [] }, {}],
1002                    "requestBody": {
1003                        "required": true,
1004                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchDocsRequest" } } }
1005                    },
1006                    "responses": {
1007                        "200": {
1008                            "description": "Array of DocSearchHits (possibly empty).",
1009                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocSearchHit" } } } }
1010                        },
1011                        "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1012                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1013                    }
1014                }
1015            },
1016            "/memory/documents/{id}": {
1017                "get": {
1018                    "summary": "Inspect one document",
1019                    "description":
1020                        "Equivalent to MCP tool `memory_inspect_document`. Returns the document's \
1021                         metadata plus a preview of every chunk (truncated to 200 chars). v0.7.0+.",
1022                    "security": [{ "bearerAuth": [] }, {}],
1023                    "parameters": [
1024                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "DocumentId (UUID v7)." }
1025                    ],
1026                    "responses": {
1027                        "200": {
1028                            "description": "Document inspection result.",
1029                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentInspectResult" } } }
1030                        },
1031                        "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1032                        "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1033                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1034                    }
1035                },
1036                "delete": {
1037                    "summary": "Forget (soft-delete) one document",
1038                    "description":
1039                        "Equivalent to MCP tool `memory_forget_document`. Flips `documents.status` \
1040                         to `forgotten` and tombstones every chunk's HNSW rowid. The chunk rows \
1041                         survive in SQL for forensic value. v0.7.0+.",
1042                    "security": [{ "bearerAuth": [] }, {}],
1043                    "parameters": [
1044                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
1045                    ],
1046                    "responses": {
1047                        "200": {
1048                            "description": "Document soft-deleted; report counts chunks tombstoned.",
1049                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgetDocumentReport" } } }
1050                        },
1051                        "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1052                        "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1053                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1054                    }
1055                }
1056            }
1057        }
1058    })
1059}
1060
1061// ---------------------------------------------------------------------------
1062// Handlers
1063// ---------------------------------------------------------------------------
1064
1065#[derive(Debug, Deserialize)]
1066struct RememberBody {
1067    content: String,
1068    #[serde(default)]
1069    source_type: Option<String>,
1070    #[serde(default)]
1071    source_id: Option<String>,
1072}
1073
1074#[derive(Debug, Serialize)]
1075struct RememberResponse {
1076    memory_id: String,
1077}
1078
1079async fn remember_handler(
1080    TenantExtractor(tenant): TenantExtractor,
1081    AuditPrincipal(principal): AuditPrincipal,
1082    Json(body): Json<RememberBody>,
1083) -> Result<Json<RememberResponse>, ApiError> {
1084    let content = body.content.trim_end().to_string();
1085    if content.is_empty() {
1086        return Err(ApiError::bad_request("content must not be empty"));
1087    }
1088    let embedding = tenant.embedder().embed(&content).await.map_err(ApiError::from)?;
1089    let episode = Episode {
1090        memory_id: MemoryId::new(),
1091        ts_ms: chrono::Utc::now().timestamp_millis(),
1092        source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
1093        source_id: body.source_id,
1094        content,
1095        encoding_context: EncodingContext::default(),
1096        provenance: None,
1097        confidence: Confidence::new(0.9).unwrap(),
1098        strength: 0.5,
1099        salience: 0.5,
1100        tier: Tier::Hot,
1101    };
1102    let mid = tenant
1103        .write()
1104        .remember_as(principal, episode, embedding)
1105        .await
1106        .map_err(ApiError::from)?;
1107    Ok(Json(RememberResponse {
1108        memory_id: mid.to_string(),
1109    }))
1110}
1111
1112#[derive(Debug, Deserialize)]
1113struct RecallBody {
1114    query: String,
1115    #[serde(default = "default_limit")]
1116    limit: usize,
1117}
1118
1119fn default_limit() -> usize {
1120    5
1121}
1122
1123async fn recall_handler(
1124    TenantExtractor(tenant): TenantExtractor,
1125    AuditPrincipal(principal): AuditPrincipal,
1126    Json(body): Json<RecallBody>,
1127) -> Result<Json<solo_query::RecallResult>, ApiError> {
1128    // solo_query::run_recall handles empty-query rejection (returns
1129    // InvalidInput → ApiError::bad_request(400)) and clamps limit
1130    // upstream of the embedder call.
1131    let result = solo_query::run_recall(tenant.as_ref(), principal, &body.query, body.limit)
1132        .await
1133        .map_err(ApiError::from)?;
1134    Ok(Json(result))
1135}
1136
1137async fn inspect_handler(
1138    TenantExtractor(tenant): TenantExtractor,
1139    AuditPrincipal(principal): AuditPrincipal,
1140    Path(id): Path<String>,
1141) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
1142    let mid = MemoryId::from_str(&id)
1143        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1144    let row = solo_query::inspect_one(tenant.read(), tenant.audit(), principal, mid)
1145        .await
1146        .map_err(ApiError::from)?;
1147    Ok(Json(row))
1148}
1149
1150// Path 1 derived-layer handlers (v0.4.0+). All three are GET-shaped:
1151// pure read-only queries against the Steward's outputs, query-string
1152// params for simple filters. Each handler delegates to a single
1153// solo_query::derived pipeline and returns the result Vec as JSON.
1154// Empty derived layer → 200 with `[]` body (parseable JSON array).
1155
1156#[derive(Debug, Deserialize)]
1157struct ThemesQuery {
1158    #[serde(default)]
1159    window_days: Option<i64>,
1160    #[serde(default = "default_limit")]
1161    limit: usize,
1162}
1163
1164async fn themes_handler(
1165    TenantExtractor(tenant): TenantExtractor,
1166    AuditPrincipal(principal): AuditPrincipal,
1167    Query(q): Query<ThemesQuery>,
1168) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
1169    let hits = solo_query::themes(
1170        tenant.read(),
1171        tenant.audit(),
1172        principal,
1173        q.window_days,
1174        q.limit,
1175    )
1176    .await
1177    .map_err(ApiError::from)?;
1178    Ok(Json(hits))
1179}
1180
1181#[derive(Debug, Deserialize)]
1182struct FactsAboutQuery {
1183    subject: String,
1184    #[serde(default)]
1185    predicate: Option<String>,
1186    #[serde(default)]
1187    since_ms: Option<i64>,
1188    #[serde(default)]
1189    until_ms: Option<i64>,
1190    /// v0.5.1 Priority 8 — widen the query to also match rows where
1191    /// `subject` appears as the object. Default `false`.
1192    #[serde(default)]
1193    include_as_object: bool,
1194    #[serde(default = "default_limit")]
1195    limit: usize,
1196}
1197
1198async fn facts_about_handler(
1199    State(s): State<SoloHttpState>,
1200    TenantExtractor(tenant): TenantExtractor,
1201    AuditPrincipal(principal): AuditPrincipal,
1202    Query(q): Query<FactsAboutQuery>,
1203) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
1204    if q.subject.trim().is_empty() {
1205        return Err(ApiError::bad_request("subject must not be empty"));
1206    }
1207    let hits = solo_query::facts_about(
1208        tenant.read(),
1209        tenant.audit(),
1210        principal,
1211        &q.subject,
1212        &s.user_aliases,
1213        q.include_as_object,
1214        q.predicate.as_deref(),
1215        q.since_ms,
1216        q.until_ms,
1217        q.limit,
1218    )
1219    .await
1220    .map_err(ApiError::from)?;
1221    Ok(Json(hits))
1222}
1223
1224#[derive(Debug, Deserialize)]
1225struct ContradictionsQuery {
1226    #[serde(default = "default_limit")]
1227    limit: usize,
1228}
1229
1230async fn contradictions_handler(
1231    TenantExtractor(tenant): TenantExtractor,
1232    AuditPrincipal(principal): AuditPrincipal,
1233    Query(q): Query<ContradictionsQuery>,
1234) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
1235    let hits = solo_query::contradictions(tenant.read(), tenant.audit(), principal, q.limit)
1236        .await
1237        .map_err(ApiError::from)?;
1238    Ok(Json(hits))
1239}
1240
1241#[derive(Debug, Deserialize, Default)]
1242struct InspectClusterQuery {
1243    /// Default `false` — episode `content` is truncated to
1244    /// `solo_query::EPISODE_TRUNCATE_CHARS` chars with a trailing `…`.
1245    /// `?full_content=true` returns each episode's content verbatim.
1246    #[serde(default)]
1247    full_content: bool,
1248}
1249
1250async fn inspect_cluster_handler(
1251    TenantExtractor(tenant): TenantExtractor,
1252    AuditPrincipal(principal): AuditPrincipal,
1253    Path(cluster_id): Path<String>,
1254    Query(q): Query<InspectClusterQuery>,
1255) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
1256    if cluster_id.trim().is_empty() {
1257        return Err(ApiError::bad_request("cluster_id must not be empty"));
1258    }
1259    let record = solo_query::inspect_cluster(
1260        tenant.read(),
1261        tenant.audit(),
1262        principal,
1263        &cluster_id,
1264        q.full_content,
1265    )
1266    .await
1267    .map_err(ApiError::from)?;
1268    Ok(Json(record))
1269}
1270
1271// ---------------------------------------------------------------------------
1272// Document handlers (v0.7.0 P6)
1273// ---------------------------------------------------------------------------
1274
1275#[derive(Debug, Deserialize)]
1276struct IngestDocumentBody {
1277    /// Server-side absolute path to the file. Must be readable by the
1278    /// Solo process. The writer reads, parses, chunks, and embeds.
1279    path: String,
1280}
1281
1282async fn ingest_document_handler(
1283    TenantExtractor(tenant): TenantExtractor,
1284    AuditPrincipal(principal): AuditPrincipal,
1285    Json(body): Json<IngestDocumentBody>,
1286) -> Result<Json<solo_storage::IngestReport>, ApiError> {
1287    if body.path.trim().is_empty() {
1288        return Err(ApiError::bad_request("path must not be empty"));
1289    }
1290    let path = std::path::PathBuf::from(body.path);
1291    let chunk_config = solo_storage::document::ChunkConfig::default();
1292    let report = tenant
1293        .write()
1294        .ingest_document_as(principal, path, chunk_config)
1295        .await
1296        .map_err(ApiError::from)?;
1297    Ok(Json(report))
1298}
1299
1300#[derive(Debug, Deserialize)]
1301struct SearchDocsBody {
1302    query: String,
1303    #[serde(default = "default_limit")]
1304    limit: usize,
1305}
1306
1307async fn search_docs_handler(
1308    TenantExtractor(tenant): TenantExtractor,
1309    AuditPrincipal(principal): AuditPrincipal,
1310    Json(body): Json<SearchDocsBody>,
1311) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
1312    let hits = solo_query::run_doc_search(tenant.as_ref(), principal, &body.query, body.limit)
1313        .await
1314        .map_err(ApiError::from)?;
1315    Ok(Json(hits))
1316}
1317
1318async fn inspect_document_handler(
1319    TenantExtractor(tenant): TenantExtractor,
1320    AuditPrincipal(principal): AuditPrincipal,
1321    Path(id): Path<String>,
1322) -> Result<Json<solo_query::DocumentInspectResult>, ApiError> {
1323    let doc_id = DocumentId::from_str(&id)
1324        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1325    let result_opt =
1326        solo_query::inspect_document(tenant.read(), tenant.audit(), principal, &doc_id)
1327            .await
1328            .map_err(ApiError::from)?;
1329    match result_opt {
1330        Some(record) => Ok(Json(record)),
1331        None => Err(ApiError::not_found(format!("document {doc_id} not found"))),
1332    }
1333}
1334
1335#[derive(Debug, Deserialize)]
1336struct ListDocumentsQuery {
1337    #[serde(default = "default_list_documents_limit")]
1338    limit: usize,
1339    #[serde(default)]
1340    offset: usize,
1341    #[serde(default)]
1342    include_forgotten: bool,
1343}
1344
1345fn default_list_documents_limit() -> usize {
1346    20
1347}
1348
1349async fn list_documents_handler(
1350    TenantExtractor(tenant): TenantExtractor,
1351    AuditPrincipal(principal): AuditPrincipal,
1352    Query(q): Query<ListDocumentsQuery>,
1353) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
1354    let rows = solo_query::list_documents(
1355        tenant.read(),
1356        tenant.audit(),
1357        principal,
1358        q.limit,
1359        q.offset,
1360        q.include_forgotten,
1361    )
1362    .await
1363    .map_err(ApiError::from)?;
1364    Ok(Json(rows))
1365}
1366
1367async fn forget_document_handler(
1368    TenantExtractor(tenant): TenantExtractor,
1369    AuditPrincipal(principal): AuditPrincipal,
1370    Path(id): Path<String>,
1371) -> Result<Json<solo_storage::ForgetDocumentReport>, ApiError> {
1372    let doc_id = DocumentId::from_str(&id)
1373        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1374    let report = tenant
1375        .write()
1376        .forget_document_as(principal, doc_id)
1377        .await
1378        .map_err(ApiError::from)?;
1379    Ok(Json(report))
1380}
1381
1382#[derive(Debug, Deserialize)]
1383struct ForgetQuery {
1384    #[serde(default)]
1385    reason: Option<String>,
1386}
1387
1388async fn forget_handler(
1389    TenantExtractor(tenant): TenantExtractor,
1390    AuditPrincipal(principal): AuditPrincipal,
1391    Path(id): Path<String>,
1392    Query(q): Query<ForgetQuery>,
1393) -> Result<StatusCode, ApiError> {
1394    let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1395    let reason = q.reason.unwrap_or_else(|| "http".into());
1396    tenant
1397        .write()
1398        .forget_as(principal, mid, reason)
1399        .await
1400        .map_err(ApiError::from)?;
1401    Ok(StatusCode::NO_CONTENT)
1402}
1403
1404async fn consolidate_handler(
1405    TenantExtractor(tenant): TenantExtractor,
1406    AuditPrincipal(principal): AuditPrincipal,
1407    body: axum::body::Bytes,
1408) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
1409    // Empty body = default scope (unbounded window). We parse via
1410    // `Bytes` rather than `Option<Json<T>>` because axum's `Json`
1411    // extractor 400s on an empty body when Content-Type is JSON
1412    // (it can't deserialize zero bytes as `T`), and the `Option`
1413    // wrapper doesn't reliably degrade that failure to `None`.
1414    let scope = if body.is_empty() {
1415        solo_storage::ConsolidationScope::default()
1416    } else {
1417        serde_json::from_slice(&body)
1418            .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
1419    };
1420    let report = tenant
1421        .write()
1422        .consolidate_as(principal, scope)
1423        .await
1424        .map_err(ApiError::from)?;
1425    Ok(Json(report))
1426}
1427
1428#[derive(Debug, Deserialize)]
1429struct BackupBody {
1430    /// Server-side absolute path where the backup file should be
1431    /// written. Must be writable by the Solo process. Refuses to
1432    /// overwrite an existing file unless `force = true`.
1433    to: String,
1434    #[serde(default)]
1435    force: bool,
1436}
1437
1438#[derive(Debug, Serialize)]
1439struct BackupResponse {
1440    path: String,
1441    elapsed_ms: u64,
1442}
1443
1444async fn backup_handler(
1445    TenantExtractor(tenant): TenantExtractor,
1446    Json(body): Json<BackupBody>,
1447) -> Result<Json<BackupResponse>, ApiError> {
1448    use std::path::PathBuf;
1449
1450    let dest = PathBuf::from(&body.to);
1451    if dest.as_os_str().is_empty() {
1452        return Err(ApiError::bad_request("`to` must not be empty"));
1453    }
1454    // CRITICAL ORDER: same-file refusal MUST come BEFORE `remove_file`.
1455    // The tenant's source DB path comes from the resolved TenantHandle.
1456    if solo_storage::paths_refer_to_same_file(tenant.db_path(), &dest) {
1457        return Err(ApiError::bad_request(format!(
1458            "destination {} is the same file as the source database; \
1459             refusing to run (would corrupt the live database)",
1460            dest.display()
1461        )));
1462    }
1463    if dest.exists() {
1464        if !body.force {
1465            return Err(ApiError::bad_request(format!(
1466                "destination {} exists; pass force=true to overwrite",
1467                dest.display()
1468            )));
1469        }
1470        std::fs::remove_file(&dest).map_err(|e| {
1471            ApiError::internal(format!(
1472                "remove existing destination {}: {e}",
1473                dest.display()
1474            ))
1475        })?;
1476    }
1477    if let Some(parent) = dest.parent() {
1478        if !parent.as_os_str().is_empty() && !parent.is_dir() {
1479            return Err(ApiError::bad_request(format!(
1480                "destination parent directory {} does not exist",
1481                parent.display()
1482            )));
1483        }
1484    }
1485
1486    let started = std::time::Instant::now();
1487    tenant.write().backup(dest.clone()).await.map_err(ApiError::from)?;
1488    let elapsed_ms = started.elapsed().as_millis() as u64;
1489
1490    Ok(Json(BackupResponse {
1491        path: dest.display().to_string(),
1492        elapsed_ms,
1493    }))
1494}
1495
1496// ---------------------------------------------------------------------------
1497// Graph expand (v0.9.x — first /v1/graph/* endpoint for solo-web)
1498// ---------------------------------------------------------------------------
1499//
1500// `GET /v1/graph/expand?node_id=...&kind=...&limit=N` — read-only neighbor
1501// drill off any node. Supports four edge kinds:
1502//   * `cluster_member` — episodes ↔ clusters via `cluster_episodes`.
1503//   * `document_chunk` — documents ↔ chunks via `document_chunks.doc_id`.
1504//   * `triple`         — episodes ↔ entities via `triples` (subject_id /
1505//     object_id / source_episode_id added in migration 0007).
1506//   * `semantic`       — HNSW top-K similar episodes (re-embeds the source
1507//     episode's content via the tenant embedder, then calls the same
1508//     pipeline as `/memory/search`; cheaper than a separate embeddings-
1509//     table fetch path and reuses one well-tested code path).
1510//
1511// **Node-id prefix convention** (locked in this PR; the future
1512// `/v1/graph/nodes` + `/v1/graph/inspect/:id` endpoints will use the
1513// same scheme):
1514//   * `ep:<memory_id>`     — episode (memory_id = UUID v7)
1515//   * `doc:<doc_id>`       — document (doc_id   = UUID v7)
1516//   * `chunk:<chunk_id>`   — chunk    (chunk_id = UUID v7)
1517//   * `cl:<cluster_id>`    — cluster
1518//   * `ent:<value>`        — entity (synthetic — minted from a triple's
1519//     subject_id / object_id; value is the raw string verbatim, no
1520//     URL-encoding — `:` and other punctuation appear in real entity
1521//     ids in the wild).
1522//
1523// Entity nodes are synthetic: there's no `entities` table. They're derived
1524// on-the-fly from triples and only exist in the wire format. Two entity
1525// nodes with the same `ent:<value>` are the same node.
1526//
1527// **Read-only**: no audit emit (lesson #30 — graph expand is a derived view
1528// over already-audited primitives; the explicit-query audit events from
1529// `memory.recall` / `memory.inspect` / `memory.facts_about` cover the
1530// underlying reads).
1531//
1532// Tests live inline in `handler_tests` below.
1533
1534const GRAPH_EXPAND_DEFAULT_LIMIT: u32 = 25;
1535const GRAPH_EXPAND_MAX_LIMIT: u32 = 100;
1536
1537/// Edge-kind discriminator. Drives which expansion path runs and what edge
1538/// kind appears in the response.
1539#[derive(Debug, Clone, Copy, Deserialize)]
1540#[serde(rename_all = "snake_case")]
1541enum GraphExpandKind {
1542    ClusterMember,
1543    DocumentChunk,
1544    Triple,
1545    Semantic,
1546}
1547
1548#[derive(Debug, Deserialize)]
1549struct GraphExpandQuery {
1550    node_id: String,
1551    kind: GraphExpandKind,
1552    #[serde(default)]
1553    limit: Option<u32>,
1554}
1555
1556/// Source-node kind, derived from the `node_id` prefix.
1557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1558enum NodeKind {
1559    Episode,
1560    Document,
1561    Chunk,
1562    Cluster,
1563    Entity,
1564}
1565
1566impl NodeKind {
1567    fn as_wire_str(self) -> &'static str {
1568        match self {
1569            Self::Episode => "episode",
1570            Self::Document => "document",
1571            Self::Chunk => "chunk",
1572            Self::Cluster => "cluster",
1573            Self::Entity => "entity",
1574        }
1575    }
1576}
1577
1578/// Decompose `<prefix>:<value>` into (kind, raw value). Returns 400 on
1579/// unknown prefix / empty value / no `:`.
1580fn parse_node_id(raw: &str) -> Result<(NodeKind, &str), ApiError> {
1581    let (prefix, value) = raw.split_once(':').ok_or_else(|| {
1582        ApiError::bad_request(format!(
1583            "node_id must be `<prefix>:<value>` (one of ep:/doc:/chunk:/cl:/ent:); got {raw:?}"
1584        ))
1585    })?;
1586    if value.is_empty() {
1587        return Err(ApiError::bad_request(format!(
1588            "node_id value is empty after prefix: {raw:?}"
1589        )));
1590    }
1591    let kind = match prefix {
1592        "ep" => NodeKind::Episode,
1593        "doc" => NodeKind::Document,
1594        "chunk" => NodeKind::Chunk,
1595        "cl" => NodeKind::Cluster,
1596        "ent" => NodeKind::Entity,
1597        other => {
1598            return Err(ApiError::bad_request(format!(
1599                "unknown node_id prefix {other:?}; expected one of ep:/doc:/chunk:/cl:/ent:"
1600            )));
1601        }
1602    };
1603    Ok((kind, value))
1604}
1605
1606/// One node in the graph-expand response. Mirrors solo-web's `GraphNode`
1607/// TS interface (see `solo-web/src/api/types.ts`).
1608#[derive(Debug, Serialize)]
1609struct GraphNode {
1610    id: String,
1611    kind: &'static str,
1612    label: String,
1613    #[serde(skip_serializing_if = "Option::is_none")]
1614    ts_ms: Option<i64>,
1615    tenant_id: String,
1616    #[serde(skip_serializing_if = "Option::is_none")]
1617    preview: Option<String>,
1618}
1619
1620/// One edge. Mirrors `GraphEdge` in solo-web TS types. `id` is a composite
1621/// `${source}--${kind}--${target}` so the renderer can dedupe.
1622#[derive(Debug, Serialize)]
1623struct GraphEdge {
1624    id: String,
1625    source: String,
1626    target: String,
1627    kind: &'static str,
1628    #[serde(skip_serializing_if = "Option::is_none")]
1629    predicate: Option<String>,
1630    #[serde(skip_serializing_if = "Option::is_none")]
1631    weight: Option<f32>,
1632}
1633
1634#[derive(Debug, Serialize)]
1635struct GraphExpandResponse {
1636    nodes: Vec<GraphNode>,
1637    edges: Vec<GraphEdge>,
1638}
1639
1640fn edge_id(source: &str, kind: &str, target: &str) -> String {
1641    format!("{source}--{kind}--{target}")
1642}
1643
1644/// Episode summary needed to mint a `GraphNode` from an episode row.
1645#[derive(Debug)]
1646struct ExpandedEpisode {
1647    memory_id: String,
1648    ts_ms: i64,
1649    content: String,
1650}
1651
1652/// Document summary.
1653#[derive(Debug)]
1654struct ExpandedDocument {
1655    doc_id: String,
1656    title: Option<String>,
1657    source: Option<String>,
1658    ingested_at_ms: i64,
1659}
1660
1661/// Chunk summary.
1662#[derive(Debug)]
1663struct ExpandedChunk {
1664    chunk_id: String,
1665    chunk_index: i64,
1666    content: String,
1667}
1668
1669fn truncate_preview(s: &str, max: usize) -> String {
1670    if s.chars().count() <= max {
1671        return s.to_string();
1672    }
1673    let mut out: String = s.chars().take(max - 1).collect();
1674    out.push('…');
1675    out
1676}
1677
1678/// First-line label cap. Keeps payloads tight for the graph renderer
1679/// (labels are headings, not full content).
1680const GRAPH_LABEL_CHARS: usize = 80;
1681const GRAPH_PREVIEW_CHARS: usize = 200;
1682
1683fn episode_label(content: &str) -> String {
1684    let first_line = content.lines().next().unwrap_or(content);
1685    truncate_preview(first_line, GRAPH_LABEL_CHARS)
1686}
1687
1688fn graph_node_for_episode(tenant_id: &str, ep: &ExpandedEpisode) -> GraphNode {
1689    GraphNode {
1690        id: format!("ep:{}", ep.memory_id),
1691        kind: NodeKind::Episode.as_wire_str(),
1692        label: episode_label(&ep.content),
1693        ts_ms: Some(ep.ts_ms),
1694        tenant_id: tenant_id.to_string(),
1695        preview: Some(truncate_preview(&ep.content, GRAPH_PREVIEW_CHARS)),
1696    }
1697}
1698
1699fn graph_node_for_document(tenant_id: &str, d: &ExpandedDocument) -> GraphNode {
1700    let label = d
1701        .title
1702        .clone()
1703        .or_else(|| d.source.clone())
1704        .unwrap_or_else(|| d.doc_id.clone());
1705    GraphNode {
1706        id: format!("doc:{}", d.doc_id),
1707        kind: NodeKind::Document.as_wire_str(),
1708        label: truncate_preview(&label, GRAPH_LABEL_CHARS),
1709        ts_ms: Some(d.ingested_at_ms),
1710        tenant_id: tenant_id.to_string(),
1711        preview: d.source.clone(),
1712    }
1713}
1714
1715fn graph_node_for_chunk(tenant_id: &str, c: &ExpandedChunk) -> GraphNode {
1716    GraphNode {
1717        id: format!("chunk:{}", c.chunk_id),
1718        kind: NodeKind::Chunk.as_wire_str(),
1719        label: format!("chunk #{}: {}", c.chunk_index, episode_label(&c.content)),
1720        ts_ms: None,
1721        tenant_id: tenant_id.to_string(),
1722        preview: Some(truncate_preview(&c.content, GRAPH_PREVIEW_CHARS)),
1723    }
1724}
1725
1726fn graph_node_for_cluster(
1727    tenant_id: &str,
1728    cluster_id: &str,
1729    abstraction: Option<&str>,
1730    created_at_ms: i64,
1731) -> GraphNode {
1732    let label = abstraction
1733        .map(|a| truncate_preview(a, GRAPH_LABEL_CHARS))
1734        .unwrap_or_else(|| format!("cluster {cluster_id}"));
1735    GraphNode {
1736        id: format!("cl:{cluster_id}"),
1737        kind: NodeKind::Cluster.as_wire_str(),
1738        label,
1739        ts_ms: Some(created_at_ms),
1740        tenant_id: tenant_id.to_string(),
1741        preview: abstraction.map(|a| truncate_preview(a, GRAPH_PREVIEW_CHARS)),
1742    }
1743}
1744
1745fn graph_node_for_entity(tenant_id: &str, value: &str) -> GraphNode {
1746    GraphNode {
1747        id: format!("ent:{value}"),
1748        kind: NodeKind::Entity.as_wire_str(),
1749        label: truncate_preview(value, GRAPH_LABEL_CHARS),
1750        ts_ms: None,
1751        tenant_id: tenant_id.to_string(),
1752        preview: None,
1753    }
1754}
1755
1756/// `GET /v1/graph/expand`. See module-level comments for the contract.
1757async fn graph_expand_handler(
1758    TenantExtractor(tenant): TenantExtractor,
1759    Query(q): Query<GraphExpandQuery>,
1760) -> Result<Json<GraphExpandResponse>, ApiError> {
1761    // Silent clamp at GRAPH_EXPAND_MAX_LIMIT — matches the rest of
1762    // solo-query's read pipelines (recall, themes, etc.). Documented in
1763    // the OpenAPI spec.
1764    let limit = q.limit.unwrap_or(GRAPH_EXPAND_DEFAULT_LIMIT);
1765    let limit = limit.clamp(1, GRAPH_EXPAND_MAX_LIMIT) as i64;
1766
1767    let (node_kind, value) = parse_node_id(&q.node_id)?;
1768    let value = value.to_string();
1769    let node_id_full = q.node_id.clone();
1770    let tenant_id_str = tenant.tenant_id().to_string();
1771
1772    match q.kind {
1773        GraphExpandKind::ClusterMember => {
1774            expand_cluster_member(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit)
1775                .await
1776        }
1777        GraphExpandKind::DocumentChunk => {
1778            expand_document_chunk(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit)
1779                .await
1780        }
1781        GraphExpandKind::Triple => {
1782            expand_triple(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit).await
1783        }
1784        GraphExpandKind::Semantic => {
1785            expand_semantic(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit).await
1786        }
1787    }
1788    .map(Json)
1789}
1790
1791// ---- cluster_member ----
1792
1793async fn expand_cluster_member(
1794    tenant: &TenantHandle,
1795    tenant_id: &str,
1796    node_kind: NodeKind,
1797    value: &str,
1798    node_id_full: &str,
1799    limit: i64,
1800) -> Result<GraphExpandResponse, ApiError> {
1801    match node_kind {
1802        NodeKind::Episode => expand_cluster_member_from_episode(
1803            tenant,
1804            tenant_id,
1805            value.to_string(),
1806            node_id_full.to_string(),
1807            limit,
1808        )
1809        .await,
1810        NodeKind::Cluster => expand_cluster_member_from_cluster(
1811            tenant,
1812            tenant_id,
1813            value.to_string(),
1814            node_id_full.to_string(),
1815            limit,
1816        )
1817        .await,
1818        _ => Err(ApiError::bad_request(format!(
1819            "kind=cluster_member only valid for episode or cluster source nodes; got {}",
1820            node_kind.as_wire_str()
1821        ))),
1822    }
1823}
1824
1825async fn expand_cluster_member_from_episode(
1826    tenant: &TenantHandle,
1827    tenant_id: &str,
1828    memory_id: String,
1829    node_id_full: String,
1830    limit: i64,
1831) -> Result<GraphExpandResponse, ApiError> {
1832    let memory_id_for_err = memory_id.clone();
1833    let rows: Vec<(String, Option<String>, i64)> = tenant
1834        .read()
1835        .interact(move |conn| {
1836            // First confirm the source episode exists in this tenant.
1837            let exists: i64 = conn.query_row(
1838                "SELECT COUNT(*) FROM episodes WHERE memory_id = ?1",
1839                rusqlite::params![&memory_id],
1840                |r| r.get(0),
1841            )?;
1842            if exists == 0 {
1843                return Ok(Vec::new());
1844            }
1845            let mut stmt = conn.prepare(
1846                "SELECT c.cluster_id, sa.content, c.created_at_ms
1847                   FROM cluster_episodes ce
1848                   JOIN clusters c ON c.cluster_id = ce.cluster_id
1849                   LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
1850                  WHERE ce.memory_id = ?1
1851                  ORDER BY c.created_at_ms DESC
1852                  LIMIT ?2",
1853            )?;
1854            let mapped = stmt
1855                .query_map(rusqlite::params![&memory_id, limit], |r| {
1856                    Ok((
1857                        r.get::<_, String>(0)?,
1858                        r.get::<_, Option<String>>(1)?,
1859                        r.get::<_, i64>(2)?,
1860                    ))
1861                })?
1862                .collect::<rusqlite::Result<Vec<_>>>()?;
1863            // Marker tuple to signal "episode found" via Vec emptiness +
1864            // an extra sentinel; we use a different shape:
1865            // pack the "found" flag via an out-of-band trick — actually
1866            // we re-query above. Keep it simple: confirm again here by
1867            // returning the rows; a missing episode short-circuits to
1868            // a 404 below via the `exists == 0` guard.
1869            Ok::<_, rusqlite::Error>(mapped)
1870        })
1871        .await
1872        .map_err(ApiError::from)?;
1873
1874    // The interact() returns Vec<(...)>; but we need to distinguish "no
1875    // such episode" (→ 404) from "episode exists, has no clusters" (→
1876    // 200 with empty arrays). Re-run a cheap existence check separately
1877    // — we already inlined it above and returned `Vec::new()` on miss,
1878    // but a real miss is indistinguishable from "episode in zero
1879    // clusters". Use a separate existence probe.
1880    if rows.is_empty() {
1881        ensure_episode_exists(tenant, &memory_id_for_err, &node_id_full).await?;
1882        return Ok(GraphExpandResponse {
1883            nodes: Vec::new(),
1884            edges: Vec::new(),
1885        });
1886    }
1887
1888    let mut nodes = Vec::with_capacity(rows.len());
1889    let mut edges = Vec::with_capacity(rows.len());
1890    for (cluster_id, abstraction, created_at_ms) in rows {
1891        let target_id = format!("cl:{cluster_id}");
1892        edges.push(GraphEdge {
1893            id: edge_id(&node_id_full, "cluster_member", &target_id),
1894            source: node_id_full.clone(),
1895            target: target_id,
1896            kind: "cluster_member",
1897            predicate: None,
1898            weight: None,
1899        });
1900        nodes.push(graph_node_for_cluster(
1901            tenant_id,
1902            &cluster_id,
1903            abstraction.as_deref(),
1904            created_at_ms,
1905        ));
1906    }
1907    Ok(GraphExpandResponse { nodes, edges })
1908}
1909
1910async fn expand_cluster_member_from_cluster(
1911    tenant: &TenantHandle,
1912    tenant_id: &str,
1913    cluster_id: String,
1914    node_id_full: String,
1915    limit: i64,
1916) -> Result<GraphExpandResponse, ApiError> {
1917    let cluster_id_for_err = cluster_id.clone();
1918    let rows: Vec<ExpandedEpisode> = tenant
1919        .read()
1920        .interact(move |conn| {
1921            let exists: i64 = conn.query_row(
1922                "SELECT COUNT(*) FROM clusters WHERE cluster_id = ?1",
1923                rusqlite::params![&cluster_id],
1924                |r| r.get(0),
1925            )?;
1926            if exists == 0 {
1927                return Ok(Vec::new());
1928            }
1929            let mut stmt = conn.prepare(
1930                "SELECT e.memory_id, e.ts_ms, e.content
1931                   FROM cluster_episodes ce
1932                   JOIN episodes e ON e.memory_id = ce.memory_id
1933                  WHERE ce.cluster_id = ?1
1934                    AND e.status = 'active'
1935                  ORDER BY e.ts_ms DESC
1936                  LIMIT ?2",
1937            )?;
1938            let mapped = stmt
1939                .query_map(rusqlite::params![&cluster_id, limit], |r| {
1940                    Ok(ExpandedEpisode {
1941                        memory_id: r.get(0)?,
1942                        ts_ms: r.get(1)?,
1943                        content: r.get(2)?,
1944                    })
1945                })?
1946                .collect::<rusqlite::Result<Vec<_>>>()?;
1947            Ok::<_, rusqlite::Error>(mapped)
1948        })
1949        .await
1950        .map_err(ApiError::from)?;
1951
1952    if rows.is_empty() {
1953        ensure_cluster_exists(tenant, &cluster_id_for_err, &node_id_full).await?;
1954        return Ok(GraphExpandResponse {
1955            nodes: Vec::new(),
1956            edges: Vec::new(),
1957        });
1958    }
1959
1960    let mut nodes = Vec::with_capacity(rows.len());
1961    let mut edges = Vec::with_capacity(rows.len());
1962    for ep in rows {
1963        let target_id = format!("ep:{}", ep.memory_id);
1964        edges.push(GraphEdge {
1965            id: edge_id(&node_id_full, "cluster_member", &target_id),
1966            source: node_id_full.clone(),
1967            target: target_id,
1968            kind: "cluster_member",
1969            predicate: None,
1970            weight: None,
1971        });
1972        nodes.push(graph_node_for_episode(tenant_id, &ep));
1973    }
1974    Ok(GraphExpandResponse { nodes, edges })
1975}
1976
1977// ---- document_chunk ----
1978
1979async fn expand_document_chunk(
1980    tenant: &TenantHandle,
1981    tenant_id: &str,
1982    node_kind: NodeKind,
1983    value: &str,
1984    node_id_full: &str,
1985    limit: i64,
1986) -> Result<GraphExpandResponse, ApiError> {
1987    match node_kind {
1988        NodeKind::Document => expand_document_chunk_from_document(
1989            tenant,
1990            tenant_id,
1991            value.to_string(),
1992            node_id_full.to_string(),
1993            limit,
1994        )
1995        .await,
1996        NodeKind::Chunk => expand_document_chunk_from_chunk(
1997            tenant,
1998            tenant_id,
1999            value.to_string(),
2000            node_id_full.to_string(),
2001        )
2002        .await,
2003        _ => Err(ApiError::bad_request(format!(
2004            "kind=document_chunk only valid for document or chunk source nodes; got {}",
2005            node_kind.as_wire_str()
2006        ))),
2007    }
2008}
2009
2010async fn expand_document_chunk_from_document(
2011    tenant: &TenantHandle,
2012    tenant_id: &str,
2013    doc_id: String,
2014    node_id_full: String,
2015    limit: i64,
2016) -> Result<GraphExpandResponse, ApiError> {
2017    let doc_id_for_err = doc_id.clone();
2018    let rows: Vec<ExpandedChunk> = tenant
2019        .read()
2020        .interact(move |conn| {
2021            let exists: i64 = conn.query_row(
2022                "SELECT COUNT(*) FROM documents WHERE doc_id = ?1",
2023                rusqlite::params![&doc_id],
2024                |r| r.get(0),
2025            )?;
2026            if exists == 0 {
2027                return Ok(Vec::new());
2028            }
2029            let mut stmt = conn.prepare(
2030                "SELECT chunk_id, chunk_index, content
2031                   FROM document_chunks
2032                  WHERE doc_id = ?1
2033                  ORDER BY chunk_index ASC
2034                  LIMIT ?2",
2035            )?;
2036            let mapped = stmt
2037                .query_map(rusqlite::params![&doc_id, limit], |r| {
2038                    Ok(ExpandedChunk {
2039                        chunk_id: r.get(0)?,
2040                        chunk_index: r.get(1)?,
2041                        content: r.get(2)?,
2042                    })
2043                })?
2044                .collect::<rusqlite::Result<Vec<_>>>()?;
2045            Ok::<_, rusqlite::Error>(mapped)
2046        })
2047        .await
2048        .map_err(ApiError::from)?;
2049
2050    if rows.is_empty() {
2051        ensure_document_exists(tenant, &doc_id_for_err, &node_id_full).await?;
2052        return Ok(GraphExpandResponse {
2053            nodes: Vec::new(),
2054            edges: Vec::new(),
2055        });
2056    }
2057
2058    let mut nodes = Vec::with_capacity(rows.len());
2059    let mut edges = Vec::with_capacity(rows.len());
2060    for c in rows {
2061        let target_id = format!("chunk:{}", c.chunk_id);
2062        edges.push(GraphEdge {
2063            id: edge_id(&node_id_full, "document_chunk", &target_id),
2064            source: node_id_full.clone(),
2065            target: target_id,
2066            kind: "document_chunk",
2067            predicate: None,
2068            weight: None,
2069        });
2070        nodes.push(graph_node_for_chunk(tenant_id, &c));
2071    }
2072    Ok(GraphExpandResponse { nodes, edges })
2073}
2074
2075async fn expand_document_chunk_from_chunk(
2076    tenant: &TenantHandle,
2077    tenant_id: &str,
2078    chunk_id: String,
2079    node_id_full: String,
2080) -> Result<GraphExpandResponse, ApiError> {
2081    let chunk_id_for_err = chunk_id.clone();
2082    let row: Option<ExpandedDocument> = tenant
2083        .read()
2084        .interact(move |conn| {
2085            conn.query_row(
2086                "SELECT d.doc_id, d.title, d.source, d.ingested_at_ms
2087                   FROM document_chunks c
2088                   JOIN documents d ON d.doc_id = c.doc_id
2089                  WHERE c.chunk_id = ?1",
2090                rusqlite::params![&chunk_id],
2091                |r| {
2092                    Ok(ExpandedDocument {
2093                        doc_id: r.get(0)?,
2094                        title: r.get(1)?,
2095                        source: r.get(2)?,
2096                        ingested_at_ms: r.get(3)?,
2097                    })
2098                },
2099            )
2100            .map(Some)
2101            .or_else(|e| match e {
2102                rusqlite::Error::QueryReturnedNoRows => Ok(None),
2103                other => Err(other),
2104            })
2105        })
2106        .await
2107        .map_err(ApiError::from)?;
2108
2109    let d = row.ok_or_else(|| {
2110        ApiError::not_found(format!(
2111            "node_id {node_id_full:?} (chunk_id {chunk_id_for_err}) not found in current tenant"
2112        ))
2113    })?;
2114    let target_id = format!("doc:{}", d.doc_id);
2115    let edge = GraphEdge {
2116        id: edge_id(&node_id_full, "document_chunk", &target_id),
2117        source: node_id_full.clone(),
2118        target: target_id,
2119        kind: "document_chunk",
2120        predicate: None,
2121        weight: None,
2122    };
2123    let node = graph_node_for_document(tenant_id, &d);
2124    Ok(GraphExpandResponse {
2125        nodes: vec![node],
2126        edges: vec![edge],
2127    })
2128}
2129
2130// ---- triple ----
2131
2132async fn expand_triple(
2133    tenant: &TenantHandle,
2134    tenant_id: &str,
2135    node_kind: NodeKind,
2136    value: &str,
2137    node_id_full: &str,
2138    limit: i64,
2139) -> Result<GraphExpandResponse, ApiError> {
2140    match node_kind {
2141        NodeKind::Episode => expand_triple_from_episode(
2142            tenant,
2143            tenant_id,
2144            value.to_string(),
2145            node_id_full.to_string(),
2146            limit,
2147        )
2148        .await,
2149        NodeKind::Entity => expand_triple_from_entity(
2150            tenant,
2151            tenant_id,
2152            value.to_string(),
2153            node_id_full.to_string(),
2154            limit,
2155        )
2156        .await,
2157        _ => Err(ApiError::bad_request(format!(
2158            "kind=triple only valid for episode or entity source nodes; got {}",
2159            node_kind.as_wire_str()
2160        ))),
2161    }
2162}
2163
2164#[derive(Debug)]
2165struct TripleRow {
2166    subject_id: String,
2167    predicate: String,
2168    object_id: String,
2169    confidence: f32,
2170}
2171
2172async fn expand_triple_from_episode(
2173    tenant: &TenantHandle,
2174    tenant_id: &str,
2175    memory_id: String,
2176    node_id_full: String,
2177    limit: i64,
2178) -> Result<GraphExpandResponse, ApiError> {
2179    let memory_id_for_err = memory_id.clone();
2180    let rows: Vec<TripleRow> = tenant
2181        .read()
2182        .interact(move |conn| {
2183            // Episode rowid lookup (triples FK is INTEGER rowid, not memory_id).
2184            let rowid_opt: Option<i64> = conn
2185                .query_row(
2186                    "SELECT rowid FROM episodes WHERE memory_id = ?1",
2187                    rusqlite::params![&memory_id],
2188                    |r| r.get(0),
2189                )
2190                .map(Some)
2191                .or_else(|e| match e {
2192                    rusqlite::Error::QueryReturnedNoRows => Ok(None),
2193                    other => Err(other),
2194                })?;
2195            let Some(rowid) = rowid_opt else {
2196                return Ok(Vec::new());
2197            };
2198            let mut stmt = conn.prepare(
2199                "SELECT subject_id, predicate, object_id, confidence
2200                   FROM triples
2201                  WHERE source_episode_id = ?1
2202                    AND status = 'active'
2203                  ORDER BY valid_from_ms DESC
2204                  LIMIT ?2",
2205            )?;
2206            let mapped = stmt
2207                .query_map(rusqlite::params![rowid, limit], |r| {
2208                    Ok(TripleRow {
2209                        subject_id: r.get(0)?,
2210                        predicate: r.get(1)?,
2211                        object_id: r.get(2)?,
2212                        confidence: r.get(3)?,
2213                    })
2214                })?
2215                .collect::<rusqlite::Result<Vec<_>>>()?;
2216            Ok::<_, rusqlite::Error>(mapped)
2217        })
2218        .await
2219        .map_err(ApiError::from)?;
2220
2221    if rows.is_empty() {
2222        ensure_episode_exists(tenant, &memory_id_for_err, &node_id_full).await?;
2223        return Ok(GraphExpandResponse {
2224            nodes: Vec::new(),
2225            edges: Vec::new(),
2226        });
2227    }
2228
2229    let mut nodes = Vec::new();
2230    let mut edges = Vec::new();
2231    let mut seen_entities: std::collections::HashSet<String> = Default::default();
2232    for t in rows {
2233        // Mint both endpoints as entity nodes. The source episode is
2234        // node_id_full; each triple becomes two edges (source→subj +
2235        // subj→obj) connected through the entity nodes, OR a single
2236        // edge labelled with the predicate from the source episode to
2237        // a representative entity. The TS schema treats `triple` as a
2238        // single edge with `predicate`; we emit one edge per triple:
2239        // source_episode → subject_entity (kind=triple, predicate=p),
2240        // plus one extra edge subject_entity → object_entity (also
2241        // kind=triple, same predicate) so a renderer can hop along the
2242        // SPO graph.
2243        let subj_id = format!("ent:{}", t.subject_id);
2244        let obj_id = format!("ent:{}", t.object_id);
2245        if seen_entities.insert(t.subject_id.clone()) {
2246            nodes.push(graph_node_for_entity(tenant_id, &t.subject_id));
2247        }
2248        if seen_entities.insert(t.object_id.clone()) {
2249            nodes.push(graph_node_for_entity(tenant_id, &t.object_id));
2250        }
2251        edges.push(GraphEdge {
2252            id: edge_id(&subj_id, "triple", &obj_id),
2253            source: subj_id,
2254            target: obj_id,
2255            kind: "triple",
2256            predicate: Some(t.predicate),
2257            weight: Some(t.confidence),
2258        });
2259    }
2260    Ok(GraphExpandResponse { nodes, edges })
2261}
2262
2263async fn expand_triple_from_entity(
2264    tenant: &TenantHandle,
2265    tenant_id: &str,
2266    entity_value: String,
2267    node_id_full: String,
2268    limit: i64,
2269) -> Result<GraphExpandResponse, ApiError> {
2270    // Entity nodes are synthetic — there's no existence check we can
2271    // run. "Unknown entity" naturally resolves to an empty result.
2272    let entity_q = entity_value.clone();
2273    let rows: Vec<ExpandedEpisode> = tenant
2274        .read()
2275        .interact(move |conn| {
2276            // Find episodes whose triples reference this entity on either
2277            // side. JOIN against episodes.rowid via triples.source_episode_id.
2278            let mut stmt = conn.prepare(
2279                "SELECT DISTINCT e.memory_id, e.ts_ms, e.content
2280                   FROM triples t
2281                   JOIN episodes e ON e.rowid = t.source_episode_id
2282                  WHERE (t.subject_id = ?1 OR t.object_id = ?1)
2283                    AND t.status = 'active'
2284                    AND t.source_episode_id IS NOT NULL
2285                    AND e.status = 'active'
2286                  ORDER BY e.ts_ms DESC
2287                  LIMIT ?2",
2288            )?;
2289            let mapped = stmt
2290                .query_map(rusqlite::params![&entity_q, limit], |r| {
2291                    Ok(ExpandedEpisode {
2292                        memory_id: r.get(0)?,
2293                        ts_ms: r.get(1)?,
2294                        content: r.get(2)?,
2295                    })
2296                })?
2297                .collect::<rusqlite::Result<Vec<_>>>()?;
2298            Ok::<_, rusqlite::Error>(mapped)
2299        })
2300        .await
2301        .map_err(ApiError::from)?;
2302
2303    // Empty result on entity expand is a valid 200 — the entity exists
2304    // only in the wire format; "no edges" is the right answer.
2305    let mut nodes = Vec::with_capacity(rows.len());
2306    let mut edges = Vec::with_capacity(rows.len());
2307    for ep in rows {
2308        let target_id = format!("ep:{}", ep.memory_id);
2309        edges.push(GraphEdge {
2310            id: edge_id(&node_id_full, "triple", &target_id),
2311            source: node_id_full.clone(),
2312            target: target_id,
2313            kind: "triple",
2314            predicate: None,
2315            weight: None,
2316        });
2317        nodes.push(graph_node_for_episode(tenant_id, &ep));
2318    }
2319    // Annotate _ to suppress unused (only used in match guard).
2320    let _ = entity_value;
2321    Ok(GraphExpandResponse { nodes, edges })
2322}
2323
2324// ---- semantic ----
2325
2326async fn expand_semantic(
2327    tenant: &TenantHandle,
2328    tenant_id: &str,
2329    node_kind: NodeKind,
2330    value: &str,
2331    node_id_full: &str,
2332    limit: i64,
2333) -> Result<GraphExpandResponse, ApiError> {
2334    if node_kind != NodeKind::Episode {
2335        return Err(ApiError::bad_request(format!(
2336            "kind=semantic only valid for episode source nodes; got {}",
2337            node_kind.as_wire_str()
2338        )));
2339    }
2340    let memory_id = value.to_string();
2341    let memory_id_q = memory_id.clone();
2342    // Fetch the source episode's content so we can re-embed it and call
2343    // the existing HNSW pipeline. Cheaper-than-extra-machinery: reuses
2344    // the well-tested `run_recall_inner` path that already filters
2345    // forgotten rows + decodes hnsw ids.
2346    let content: Option<String> = tenant
2347        .read()
2348        .interact(move |conn| {
2349            conn.query_row(
2350                "SELECT content FROM episodes WHERE memory_id = ?1 AND status = 'active'",
2351                rusqlite::params![&memory_id_q],
2352                |r| r.get::<_, String>(0),
2353            )
2354            .map(Some)
2355            .or_else(|e| match e {
2356                rusqlite::Error::QueryReturnedNoRows => Ok(None),
2357                other => Err(other),
2358            })
2359        })
2360        .await
2361        .map_err(ApiError::from)?;
2362
2363    let content = content.ok_or_else(|| {
2364        ApiError::not_found(format!(
2365            "node_id {node_id_full:?} (memory_id {memory_id}) not found in current tenant"
2366        ))
2367    })?;
2368
2369    // Pull one extra hit so we can drop self without losing user-requested
2370    // count. limit is already ≤ MAX_LIMIT; +1 stays within reason.
2371    let widened = (limit as usize).saturating_add(1).min(100);
2372    let result = solo_query::recall::run_recall_inner(
2373        tenant.embedder(),
2374        tenant.hnsw(),
2375        tenant.read(),
2376        &content,
2377        widened,
2378    )
2379    .await
2380    .map_err(ApiError::from)?;
2381
2382    let mut nodes = Vec::new();
2383    let mut edges = Vec::new();
2384    for hit in result.hits.into_iter() {
2385        if hit.memory_id == memory_id {
2386            // Skip self.
2387            continue;
2388        }
2389        if nodes.len() as i64 >= limit {
2390            break;
2391        }
2392        // The HNSW `cos_distance` is a distance (smaller = more similar).
2393        // Convert to a weight in [0, 1] (larger = more similar) for the
2394        // wire format: weight = (1 - distance).max(0).
2395        let weight = (1.0 - hit.cos_distance).max(0.0);
2396        let target_id = format!("ep:{}", hit.memory_id);
2397        edges.push(GraphEdge {
2398            id: edge_id(node_id_full, "semantic", &target_id),
2399            source: node_id_full.to_string(),
2400            target: target_id,
2401            kind: "semantic",
2402            predicate: None,
2403            weight: Some(weight),
2404        });
2405        nodes.push(GraphNode {
2406            id: format!("ep:{}", hit.memory_id),
2407            kind: NodeKind::Episode.as_wire_str(),
2408            label: episode_label(&hit.content),
2409            ts_ms: None,
2410            tenant_id: tenant_id.to_string(),
2411            preview: Some(truncate_preview(&hit.content, GRAPH_PREVIEW_CHARS)),
2412        });
2413    }
2414    Ok(GraphExpandResponse { nodes, edges })
2415}
2416
2417// ---- existence checks ----
2418
2419/// 404 if the memory_id has no row in this tenant's `episodes` table.
2420async fn ensure_episode_exists(
2421    tenant: &TenantHandle,
2422    memory_id: &str,
2423    node_id_full: &str,
2424) -> Result<(), ApiError> {
2425    let memory_id_q = memory_id.to_string();
2426    let exists: i64 = tenant
2427        .read()
2428        .interact(move |conn| {
2429            conn.query_row(
2430                "SELECT COUNT(*) FROM episodes WHERE memory_id = ?1",
2431                rusqlite::params![&memory_id_q],
2432                |r| r.get(0),
2433            )
2434        })
2435        .await
2436        .map_err(ApiError::from)?;
2437    if exists == 0 {
2438        return Err(ApiError::not_found(format!(
2439            "node_id {node_id_full:?} not found in current tenant"
2440        )));
2441    }
2442    Ok(())
2443}
2444
2445async fn ensure_cluster_exists(
2446    tenant: &TenantHandle,
2447    cluster_id: &str,
2448    node_id_full: &str,
2449) -> Result<(), ApiError> {
2450    let cluster_id_q = cluster_id.to_string();
2451    let exists: i64 = tenant
2452        .read()
2453        .interact(move |conn| {
2454            conn.query_row(
2455                "SELECT COUNT(*) FROM clusters WHERE cluster_id = ?1",
2456                rusqlite::params![&cluster_id_q],
2457                |r| r.get(0),
2458            )
2459        })
2460        .await
2461        .map_err(ApiError::from)?;
2462    if exists == 0 {
2463        return Err(ApiError::not_found(format!(
2464            "node_id {node_id_full:?} not found in current tenant"
2465        )));
2466    }
2467    Ok(())
2468}
2469
2470async fn ensure_document_exists(
2471    tenant: &TenantHandle,
2472    doc_id: &str,
2473    node_id_full: &str,
2474) -> Result<(), ApiError> {
2475    let doc_id_q = doc_id.to_string();
2476    let exists: i64 = tenant
2477        .read()
2478        .interact(move |conn| {
2479            conn.query_row(
2480                "SELECT COUNT(*) FROM documents WHERE doc_id = ?1",
2481                rusqlite::params![&doc_id_q],
2482                |r| r.get(0),
2483            )
2484        })
2485        .await
2486        .map_err(ApiError::from)?;
2487    if exists == 0 {
2488        return Err(ApiError::not_found(format!(
2489            "node_id {node_id_full:?} not found in current tenant"
2490        )));
2491    }
2492    Ok(())
2493}
2494
2495// ---------------------------------------------------------------------------
2496// Graph nodes + edges — paginated catalog reads (v0.10.0)
2497//
2498// `GET /v1/graph/nodes` and `GET /v1/graph/edges` are the bundle that
2499// powers solo-web's initial graph render. Both are read-only, both
2500// share the same tenant / auth / cursor scaffolding, both inherit the
2501// node-id prefix convention from `/v1/graph/expand` (ep:/doc:/chunk:/cl:/ent:).
2502//
2503// See `docs/dev-log/0114-graph-nodes-edges-impl.md` for the design
2504// notes (cursor format, entity scan strategy, semantic-edge rejection
2505// rationale, UNION pagination shape).
2506// ---------------------------------------------------------------------------
2507
2508const GRAPH_NODES_DEFAULT_LIMIT: u32 = 100;
2509const GRAPH_NODES_MAX_LIMIT: u32 = 1000;
2510const GRAPH_EDGES_DEFAULT_LIMIT: u32 = 200;
2511const GRAPH_EDGES_MAX_LIMIT: u32 = 2000;
2512const GRAPH_ENTITY_CAP: usize = 200;
2513
2514/// Header set when the entity scan hit `GRAPH_ENTITY_CAP` and lower-
2515/// frequency entities were dropped from the response. Clients can show
2516/// "entities truncated" UX without parsing the body.
2517const ENTITY_CAP_HEADER: &str = "x-solo-entity-cap-reached";
2518
2519#[derive(Debug, Deserialize)]
2520struct GraphNodesQuery {
2521    /// Comma-separated kinds. Empty/missing = all five kinds. Repeated
2522    /// `?kind=` query params are NOT supported by axum's `Query<T>`
2523    /// extractor for `Option<String>` (it picks one) — comma-separated
2524    /// is documented + simpler. Values: episode|document|chunk|cluster|entity.
2525    #[serde(default)]
2526    kind: Option<String>,
2527    #[serde(default)]
2528    since_ms: Option<i64>,
2529    #[serde(default)]
2530    until_ms: Option<i64>,
2531    #[serde(default)]
2532    limit: Option<u32>,
2533    #[serde(default)]
2534    cursor: Option<String>,
2535}
2536
2537#[derive(Debug, Deserialize)]
2538struct GraphEdgesQuery {
2539    #[serde(default)]
2540    node_id: Option<String>,
2541    /// Comma-separated. Default = all kinds EXCEPT semantic.
2542    /// Values: triple|document_chunk|cluster_member|semantic.
2543    #[serde(default)]
2544    r#type: Option<String>,
2545    #[serde(default)]
2546    limit: Option<u32>,
2547    #[serde(default)]
2548    cursor: Option<String>,
2549}
2550
2551#[derive(Debug, Serialize)]
2552struct GraphNodesResponse {
2553    nodes: Vec<GraphNode>,
2554    #[serde(skip_serializing_if = "Option::is_none")]
2555    next_cursor: Option<String>,
2556}
2557
2558#[derive(Debug, Serialize)]
2559struct GraphEdgesResponse {
2560    edges: Vec<GraphEdge>,
2561    #[serde(skip_serializing_if = "Option::is_none")]
2562    next_cursor: Option<String>,
2563}
2564
2565/// Decode the `kind` filter from the query string. Returns the set of
2566/// kinds the caller wants (all five when filter absent / empty). 400 on
2567/// unknown kind.
2568fn parse_node_kind_filter(raw: Option<&str>) -> Result<Vec<NodeKind>, ApiError> {
2569    let raw = raw.unwrap_or("").trim();
2570    if raw.is_empty() {
2571        return Ok(vec![
2572            NodeKind::Episode,
2573            NodeKind::Document,
2574            NodeKind::Chunk,
2575            NodeKind::Cluster,
2576            NodeKind::Entity,
2577        ]);
2578    }
2579    let mut out = Vec::new();
2580    for token in raw.split(',') {
2581        let token = token.trim();
2582        if token.is_empty() {
2583            continue;
2584        }
2585        let kind = match token {
2586            "episode" => NodeKind::Episode,
2587            "document" => NodeKind::Document,
2588            "chunk" => NodeKind::Chunk,
2589            "cluster" => NodeKind::Cluster,
2590            "entity" => NodeKind::Entity,
2591            other => {
2592                return Err(ApiError::bad_request(format!(
2593                    "unknown node kind {other:?}; expected one of episode/document/chunk/cluster/entity"
2594                )));
2595            }
2596        };
2597        if !out.contains(&kind) {
2598            out.push(kind);
2599        }
2600    }
2601    if out.is_empty() {
2602        return Err(ApiError::bad_request(
2603            "kind filter is empty after parsing; either omit or list at least one kind",
2604        ));
2605    }
2606    Ok(out)
2607}
2608
2609/// Edge-kind discriminator on `/v1/graph/edges`.
2610#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2611enum EdgeKind {
2612    Triple,
2613    DocumentChunk,
2614    ClusterMember,
2615}
2616
2617impl EdgeKind {
2618    /// Sort-stable kind ordering for pagination. Lower runs first.
2619    fn order_idx(self) -> u8 {
2620        match self {
2621            Self::Triple => 0,
2622            Self::DocumentChunk => 1,
2623            Self::ClusterMember => 2,
2624        }
2625    }
2626}
2627
2628fn parse_edge_kind_filter(raw: Option<&str>) -> Result<Vec<EdgeKind>, ApiError> {
2629    let raw = raw.unwrap_or("").trim();
2630    if raw.is_empty() {
2631        // Default = all three concrete kinds; semantic is opt-in via
2632        // /v1/graph/neighbors/:id (per scoping doc §3 Decision B).
2633        return Ok(vec![
2634            EdgeKind::Triple,
2635            EdgeKind::DocumentChunk,
2636            EdgeKind::ClusterMember,
2637        ]);
2638    }
2639    let mut out = Vec::new();
2640    for token in raw.split(',') {
2641        let token = token.trim();
2642        if token.is_empty() {
2643            continue;
2644        }
2645        let kind = match token {
2646            "triple" => EdgeKind::Triple,
2647            "document_chunk" => EdgeKind::DocumentChunk,
2648            "cluster_member" => EdgeKind::ClusterMember,
2649            "semantic" => {
2650                // semantic edges aren't precomputed; they're HNSW queries
2651                // at request time. Wrong endpoint.
2652                return Err(ApiError::bad_request(
2653                    "semantic edges are available via /v1/graph/neighbors/:id?kind=semantic, not /v1/graph/edges (semantic edges aren't precomputed; they're query-time HNSW lookups)",
2654                ));
2655            }
2656            other => {
2657                return Err(ApiError::bad_request(format!(
2658                    "unknown edge type {other:?}; expected one of triple/document_chunk/cluster_member"
2659                )));
2660            }
2661        };
2662        if !out.contains(&kind) {
2663            out.push(kind);
2664        }
2665    }
2666    if out.is_empty() {
2667        return Err(ApiError::bad_request(
2668            "type filter is empty after parsing; either omit or list at least one type",
2669        ));
2670    }
2671    Ok(out)
2672}
2673
2674/// Opaque cursor for `/v1/graph/nodes`. Encodes the last item's
2675/// `(ts_ms, id)` so the next page is `WHERE (ts_ms, id) < (cursor.ts_ms,
2676/// cursor.id)` under sort `ts_ms DESC, id ASC`.
2677#[derive(Debug, Serialize, Deserialize)]
2678struct NodesCursor {
2679    ts_ms: i64,
2680    id: String,
2681}
2682
2683/// Opaque cursor for `/v1/graph/edges`. Encodes the last item's
2684/// `(kind_idx, sub_id)` so the next page resumes at `> cursor` under
2685/// sort `(kind_idx ASC, sub_id ASC)`. `sub_id` is the per-kind stable
2686/// row id (triple_id for triples, chunk_id for document_chunk, the
2687/// composite `cluster_id||memory_id` string for cluster_member).
2688#[derive(Debug, Serialize, Deserialize)]
2689struct EdgesCursor {
2690    kind_idx: u8,
2691    sub_id: String,
2692}
2693
2694fn encode_cursor<T: Serialize>(value: &T) -> Result<String, ApiError> {
2695    use base64::Engine;
2696    let json = serde_json::to_vec(value).map_err(|e| {
2697        ApiError::internal(format!("cursor serialize: {e}"))
2698    })?;
2699    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json))
2700}
2701
2702fn decode_cursor<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, ApiError> {
2703    use base64::Engine;
2704    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
2705        .decode(raw.as_bytes())
2706        .map_err(|e| ApiError::bad_request(format!("cursor: bad base64: {e}")))?;
2707    serde_json::from_slice::<T>(&bytes)
2708        .map_err(|e| ApiError::bad_request(format!("cursor: bad JSON payload: {e}")))
2709}
2710
2711/// Internal staging row for the nodes endpoint. Carries the GraphNode
2712/// plus the sort key so we can merge all kinds before applying the
2713/// pagination cut.
2714#[derive(Debug)]
2715struct StagingNode {
2716    node: GraphNode,
2717    sort_ts_ms: i64,
2718    sort_id: String,
2719}
2720
2721/// Apply `ts_ms DESC, id ASC` ordering. (Newest first, deterministic
2722/// tie-break on id.)
2723fn cmp_node_sort_keys(a: (i64, &str), b: (i64, &str)) -> std::cmp::Ordering {
2724    // ts_ms DESC: invert
2725    match b.0.cmp(&a.0) {
2726        std::cmp::Ordering::Equal => a.1.cmp(b.1), // id ASC
2727        other => other,
2728    }
2729}
2730
2731/// True if `(ts_ms, id)` strictly comes AFTER `cursor` under the canonical
2732/// sort `ts_ms DESC, id ASC` — i.e. is admissible into a page following
2733/// the cursor.
2734fn node_passes_cursor(ts_ms: i64, id: &str, cursor: &NodesCursor) -> bool {
2735    cmp_node_sort_keys((ts_ms, id), (cursor.ts_ms, cursor.id.as_str()))
2736        == std::cmp::Ordering::Greater
2737}
2738
2739// --- Per-kind row fetchers (each runs a bounded query, applies the time
2740//     filter, returns rows already sorted `ts_ms DESC, id ASC`).
2741
2742#[derive(Debug)]
2743struct NodeRowEp {
2744    memory_id: String,
2745    ts_ms: i64,
2746    content: String,
2747}
2748
2749fn fetch_episodes_for_nodes(
2750    conn: &rusqlite::Connection,
2751    since_ms: Option<i64>,
2752    until_ms: Option<i64>,
2753    cursor: Option<&NodesCursor>,
2754    limit: i64,
2755) -> rusqlite::Result<Vec<NodeRowEp>> {
2756    let mut sql = String::from(
2757        "SELECT memory_id, ts_ms, content
2758           FROM episodes
2759          WHERE status = 'active'",
2760    );
2761    let mut params: Vec<rusqlite::types::Value> = Vec::new();
2762    if let Some(s) = since_ms {
2763        sql.push_str(" AND ts_ms >= ?");
2764        params.push(s.into());
2765    }
2766    if let Some(u) = until_ms {
2767        sql.push_str(" AND ts_ms <= ?");
2768        params.push(u.into());
2769    }
2770    // Cursor pre-filter: under sort `ts_ms DESC, prefixed_id ASC`,
2771    // anything strictly newer than the cursor's ts_ms is in a previous
2772    // page; rows with equal ts_ms may or may not be (depends on the
2773    // cross-kind ordering). The post-merge step applies the full
2774    // `(ts_ms, prefixed_id)` comparison; here we just discard rows
2775    // that can't possibly survive.
2776    if let Some(cur) = cursor {
2777        sql.push_str(" AND ts_ms <= ?");
2778        params.push(cur.ts_ms.into());
2779    }
2780    sql.push_str(" ORDER BY ts_ms DESC, memory_id ASC LIMIT ?");
2781    params.push(limit.into());
2782    let mut stmt = conn.prepare(&sql)?;
2783    let rows: Vec<NodeRowEp> = stmt
2784        .query_map(rusqlite::params_from_iter(params), |r| {
2785            Ok(NodeRowEp {
2786                memory_id: r.get(0)?,
2787                ts_ms: r.get(1)?,
2788                content: r.get(2)?,
2789            })
2790        })?
2791        .collect::<rusqlite::Result<Vec<_>>>()?;
2792    Ok(rows)
2793}
2794
2795#[derive(Debug)]
2796struct NodeRowDoc {
2797    doc_id: String,
2798    title: Option<String>,
2799    source: Option<String>,
2800    ingested_at_ms: i64,
2801}
2802
2803fn fetch_documents_for_nodes(
2804    conn: &rusqlite::Connection,
2805    since_ms: Option<i64>,
2806    until_ms: Option<i64>,
2807    cursor: Option<&NodesCursor>,
2808    limit: i64,
2809) -> rusqlite::Result<Vec<NodeRowDoc>> {
2810    let mut sql = String::from(
2811        "SELECT doc_id, title, source, ingested_at_ms
2812           FROM documents
2813          WHERE status = 'active'",
2814    );
2815    let mut params: Vec<rusqlite::types::Value> = Vec::new();
2816    if let Some(s) = since_ms {
2817        sql.push_str(" AND ingested_at_ms >= ?");
2818        params.push(s.into());
2819    }
2820    if let Some(u) = until_ms {
2821        sql.push_str(" AND ingested_at_ms <= ?");
2822        params.push(u.into());
2823    }
2824    if let Some(cur) = cursor {
2825        sql.push_str(" AND ingested_at_ms <= ?");
2826        params.push(cur.ts_ms.into());
2827    }
2828    sql.push_str(" ORDER BY ingested_at_ms DESC, doc_id ASC LIMIT ?");
2829    params.push(limit.into());
2830    let mut stmt = conn.prepare(&sql)?;
2831    let rows: Vec<NodeRowDoc> = stmt
2832        .query_map(rusqlite::params_from_iter(params), |r| {
2833            Ok(NodeRowDoc {
2834                doc_id: r.get(0)?,
2835                title: r.get(1)?,
2836                source: r.get(2)?,
2837                ingested_at_ms: r.get(3)?,
2838            })
2839        })?
2840        .collect::<rusqlite::Result<Vec<_>>>()?;
2841    Ok(rows)
2842}
2843
2844#[derive(Debug)]
2845struct NodeRowChunk {
2846    chunk_id: String,
2847    chunk_index: i64,
2848    content: String,
2849    created_at_ms: i64,
2850}
2851
2852fn fetch_chunks_for_nodes(
2853    conn: &rusqlite::Connection,
2854    since_ms: Option<i64>,
2855    until_ms: Option<i64>,
2856    cursor: Option<&NodesCursor>,
2857    limit: i64,
2858) -> rusqlite::Result<Vec<NodeRowChunk>> {
2859    // Filter by `document_chunks.created_at_ms`; chunks of forgotten
2860    // documents are filtered out by the join on `documents.status`.
2861    let mut sql = String::from(
2862        "SELECT c.chunk_id, c.chunk_index, c.content, c.created_at_ms
2863           FROM document_chunks c
2864           JOIN documents d ON d.doc_id = c.doc_id
2865          WHERE d.status = 'active'",
2866    );
2867    let mut params: Vec<rusqlite::types::Value> = Vec::new();
2868    if let Some(s) = since_ms {
2869        sql.push_str(" AND c.created_at_ms >= ?");
2870        params.push(s.into());
2871    }
2872    if let Some(u) = until_ms {
2873        sql.push_str(" AND c.created_at_ms <= ?");
2874        params.push(u.into());
2875    }
2876    if let Some(cur) = cursor {
2877        sql.push_str(" AND c.created_at_ms <= ?");
2878        params.push(cur.ts_ms.into());
2879    }
2880    sql.push_str(" ORDER BY c.created_at_ms DESC, c.chunk_id ASC LIMIT ?");
2881    params.push(limit.into());
2882    let mut stmt = conn.prepare(&sql)?;
2883    let rows: Vec<NodeRowChunk> = stmt
2884        .query_map(rusqlite::params_from_iter(params), |r| {
2885            Ok(NodeRowChunk {
2886                chunk_id: r.get(0)?,
2887                chunk_index: r.get(1)?,
2888                content: r.get(2)?,
2889                created_at_ms: r.get(3)?,
2890            })
2891        })?
2892        .collect::<rusqlite::Result<Vec<_>>>()?;
2893    Ok(rows)
2894}
2895
2896#[derive(Debug)]
2897struct NodeRowCluster {
2898    cluster_id: String,
2899    abstraction: Option<String>,
2900    created_at_ms: i64,
2901}
2902
2903fn fetch_clusters_for_nodes(
2904    conn: &rusqlite::Connection,
2905    since_ms: Option<i64>,
2906    until_ms: Option<i64>,
2907    cursor: Option<&NodesCursor>,
2908    limit: i64,
2909) -> rusqlite::Result<Vec<NodeRowCluster>> {
2910    // clusters has no `status` column; LEFT JOIN abstractions for the
2911    // optional label.
2912    let mut sql = String::from(
2913        "SELECT c.cluster_id, sa.content, c.created_at_ms
2914           FROM clusters c
2915           LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
2916          WHERE 1=1",
2917    );
2918    let mut params: Vec<rusqlite::types::Value> = Vec::new();
2919    if let Some(s) = since_ms {
2920        sql.push_str(" AND c.created_at_ms >= ?");
2921        params.push(s.into());
2922    }
2923    if let Some(u) = until_ms {
2924        sql.push_str(" AND c.created_at_ms <= ?");
2925        params.push(u.into());
2926    }
2927    if let Some(cur) = cursor {
2928        sql.push_str(" AND c.created_at_ms <= ?");
2929        params.push(cur.ts_ms.into());
2930    }
2931    sql.push_str(" ORDER BY c.created_at_ms DESC, c.cluster_id ASC LIMIT ?");
2932    params.push(limit.into());
2933    let mut stmt = conn.prepare(&sql)?;
2934    let rows: Vec<NodeRowCluster> = stmt
2935        .query_map(rusqlite::params_from_iter(params), |r| {
2936            Ok(NodeRowCluster {
2937                cluster_id: r.get(0)?,
2938                abstraction: r.get(1)?,
2939                created_at_ms: r.get(2)?,
2940            })
2941        })?
2942        .collect::<rusqlite::Result<Vec<_>>>()?;
2943    Ok(rows)
2944}
2945
2946#[derive(Debug)]
2947struct NodeRowEntity {
2948    value: String,
2949    ref_count: i64,
2950    first_seen_ms: i64,
2951}
2952
2953/// Synthesize entity nodes from the triples table. Caps result at
2954/// `GRAPH_ENTITY_CAP`, ordered by `ref_count DESC` so the loudest
2955/// entities make the cut. Returns (rows, cap_reached).
2956///
2957/// **Cost**: this is O(N) over active triples per request. For tenants
2958/// with >100k triples this can be noticeable; v0.10.x can cache the
2959/// rollup if profiling justifies it. The 200-row cap keeps the wire
2960/// payload bounded regardless.
2961fn fetch_entities_for_nodes(
2962    conn: &rusqlite::Connection,
2963    since_ms: Option<i64>,
2964    until_ms: Option<i64>,
2965    cursor: Option<&NodesCursor>,
2966) -> rusqlite::Result<(Vec<NodeRowEntity>, bool)> {
2967    // Pull subject + object columns, group by value, compute count + min
2968    // ts_ms. UNION ALL the two columns into a single aggregation. Apply
2969    // time filter against `valid_from_ms` (the closest analogue to "when
2970    // was this entity first referenced").
2971    let mut sql = String::from(
2972        "WITH all_refs AS (
2973            SELECT subject_id AS value, valid_from_ms AS ts_ms FROM triples WHERE status = 'active'
2974            UNION ALL
2975            SELECT object_id  AS value, valid_from_ms AS ts_ms FROM triples WHERE status = 'active'
2976         )
2977         SELECT value, COUNT(*) AS ref_count, MIN(ts_ms) AS first_seen_ms
2978           FROM all_refs
2979          WHERE 1=1",
2980    );
2981    let mut params: Vec<rusqlite::types::Value> = Vec::new();
2982    if let Some(s) = since_ms {
2983        sql.push_str(" AND ts_ms >= ?");
2984        params.push(s.into());
2985    }
2986    if let Some(u) = until_ms {
2987        sql.push_str(" AND ts_ms <= ?");
2988        params.push(u.into());
2989    }
2990    // Cursor: drop entities whose first_seen_ms strictly newer than the
2991    // cursor. We can't predicate on COUNT() until after GROUP BY, so the
2992    // cap-applicable filter sits in the HAVING clause.
2993    sql.push_str(" GROUP BY value");
2994    if let Some(ts) = cursor.map(|c| c.ts_ms) {
2995        sql.push_str(" HAVING MIN(ts_ms) <= ?");
2996        params.push(ts.into());
2997    }
2998    // Over-fetch by one to detect "cap reached".
2999    let want = GRAPH_ENTITY_CAP as i64 + 1;
3000    sql.push_str(" ORDER BY ref_count DESC, value ASC LIMIT ?");
3001    params.push(want.into());
3002    let mut stmt = conn.prepare(&sql)?;
3003    let rows: Vec<NodeRowEntity> = stmt
3004        .query_map(rusqlite::params_from_iter(params), |r| {
3005            Ok(NodeRowEntity {
3006                value: r.get(0)?,
3007                ref_count: r.get(1)?,
3008                first_seen_ms: r.get(2)?,
3009            })
3010        })?
3011        .collect::<rusqlite::Result<Vec<_>>>()?;
3012    let cap_reached = rows.len() > GRAPH_ENTITY_CAP;
3013    let mut trimmed = rows;
3014    if cap_reached {
3015        trimmed.truncate(GRAPH_ENTITY_CAP);
3016    }
3017    Ok((trimmed, cap_reached))
3018}
3019
3020/// `GET /v1/graph/nodes`. Paginated node catalog across the tenant.
3021/// See module-level comments for the contract.
3022async fn graph_nodes_handler(
3023    TenantExtractor(tenant): TenantExtractor,
3024    Query(q): Query<GraphNodesQuery>,
3025) -> Result<Response, ApiError> {
3026    let limit = q.limit.unwrap_or(GRAPH_NODES_DEFAULT_LIMIT);
3027    let limit = limit.clamp(1, GRAPH_NODES_MAX_LIMIT);
3028    let kinds = parse_node_kind_filter(q.kind.as_deref())?;
3029    let since_ms = q.since_ms;
3030    let until_ms = q.until_ms;
3031    if let (Some(s), Some(u)) = (since_ms, until_ms) {
3032        if s > u {
3033            return Err(ApiError::bad_request(format!(
3034                "since_ms ({s}) must be <= until_ms ({u})"
3035            )));
3036        }
3037    }
3038    let cursor = match q.cursor.as_deref() {
3039        None => None,
3040        Some("") => None,
3041        Some(raw) => Some(decode_cursor::<NodesCursor>(raw)?),
3042    };
3043    let want_episode = kinds.contains(&NodeKind::Episode);
3044    let want_document = kinds.contains(&NodeKind::Document);
3045    let want_chunk = kinds.contains(&NodeKind::Chunk);
3046    let want_cluster = kinds.contains(&NodeKind::Cluster);
3047    let want_entity = kinds.contains(&NodeKind::Entity);
3048
3049    // Over-fetch `limit + 2` per kind:
3050    //   * `+1` so the merge step can detect "more rows available beyond
3051    //     this page" → emits a `next_cursor` instead of None.
3052    //   * `+1` again because the SQL pre-filter `ts_ms <= cursor.ts_ms`
3053    //     can pull the previous page's last item back in; the post-merge
3054    //     cursor predicate drops it, costing one row of headroom.
3055    // The entity cap stays at GRAPH_ENTITY_CAP — entities are bounded
3056    // independently by the response cap, not the page limit.
3057    let per_kind_limit = (limit as i64).saturating_add(2);
3058    let tenant_id_for_blocking = tenant.tenant_id().to_string();
3059    let cursor_clone = cursor.as_ref().map(|c| NodesCursor {
3060        ts_ms: c.ts_ms,
3061        id: c.id.clone(),
3062    });
3063
3064    let (mut staged, cap_reached) = tenant
3065        .read()
3066        .interact(move |conn| {
3067            let mut staged: Vec<StagingNode> = Vec::new();
3068            let mut cap_reached = false;
3069            let cursor_ref = cursor_clone.as_ref();
3070
3071            if want_episode {
3072                let eps = fetch_episodes_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3073                for ep in eps {
3074                    let id = format!("ep:{}", ep.memory_id);
3075                    let exp = ExpandedEpisode {
3076                        memory_id: ep.memory_id,
3077                        ts_ms: ep.ts_ms,
3078                        content: ep.content,
3079                    };
3080                    let node = graph_node_for_episode(&tenant_id_for_blocking, &exp);
3081                    staged.push(StagingNode {
3082                        sort_ts_ms: ep.ts_ms,
3083                        sort_id: id.clone(),
3084                        node,
3085                    });
3086                }
3087            }
3088            if want_document {
3089                let docs = fetch_documents_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3090                for d in docs {
3091                    let id = format!("doc:{}", d.doc_id);
3092                    let exp = ExpandedDocument {
3093                        doc_id: d.doc_id,
3094                        title: d.title,
3095                        source: d.source,
3096                        ingested_at_ms: d.ingested_at_ms,
3097                    };
3098                    let node = graph_node_for_document(&tenant_id_for_blocking, &exp);
3099                    staged.push(StagingNode {
3100                        sort_ts_ms: d.ingested_at_ms,
3101                        sort_id: id.clone(),
3102                        node,
3103                    });
3104                }
3105            }
3106            if want_chunk {
3107                let chunks = fetch_chunks_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3108                for c in chunks {
3109                    let id = format!("chunk:{}", c.chunk_id);
3110                    let exp = ExpandedChunk {
3111                        chunk_id: c.chunk_id,
3112                        chunk_index: c.chunk_index,
3113                        content: c.content,
3114                    };
3115                    // graph_node_for_chunk sets ts_ms = None for the
3116                    // wire format (chunks don't have a natural user-
3117                    // facing timestamp); but for sorting we use the
3118                    // row's created_at_ms.
3119                    let mut node = graph_node_for_chunk(&tenant_id_for_blocking, &exp);
3120                    node.ts_ms = Some(c.created_at_ms);
3121                    staged.push(StagingNode {
3122                        sort_ts_ms: c.created_at_ms,
3123                        sort_id: id.clone(),
3124                        node,
3125                    });
3126                }
3127            }
3128            if want_cluster {
3129                let cls = fetch_clusters_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3130                for c in cls {
3131                    let id = format!("cl:{}", c.cluster_id);
3132                    let node = graph_node_for_cluster(
3133                        &tenant_id_for_blocking,
3134                        &c.cluster_id,
3135                        c.abstraction.as_deref(),
3136                        c.created_at_ms,
3137                    );
3138                    staged.push(StagingNode {
3139                        sort_ts_ms: c.created_at_ms,
3140                        sort_id: id.clone(),
3141                        node,
3142                    });
3143                }
3144            }
3145            if want_entity {
3146                let (ents, was_cap_reached) =
3147                    fetch_entities_for_nodes(conn, since_ms, until_ms, cursor_ref)?;
3148                cap_reached = was_cap_reached;
3149                for e in ents {
3150                    let id = format!("ent:{}", e.value);
3151                    let mut node = graph_node_for_entity(&tenant_id_for_blocking, &e.value);
3152                    node.ts_ms = Some(e.first_seen_ms);
3153                    node.preview =
3154                        Some(format!("Referenced in {} triples", e.ref_count));
3155                    staged.push(StagingNode {
3156                        sort_ts_ms: e.first_seen_ms,
3157                        sort_id: id.clone(),
3158                        node,
3159                    });
3160                }
3161            }
3162            Ok::<_, rusqlite::Error>((staged, cap_reached))
3163        })
3164        .await
3165        .map_err(ApiError::from)?;
3166
3167    // Apply cursor filter.
3168    if let Some(cur) = &cursor {
3169        staged.retain(|s| node_passes_cursor(s.sort_ts_ms, &s.sort_id, cur));
3170    }
3171
3172    // Sort `ts_ms DESC, id ASC`.
3173    staged.sort_by(|a, b| {
3174        cmp_node_sort_keys((a.sort_ts_ms, &a.sort_id), (b.sort_ts_ms, &b.sort_id))
3175    });
3176
3177    // Apply page limit + compute next_cursor.
3178    let limit_us = limit as usize;
3179    let next_cursor = if staged.len() > limit_us {
3180        let last = &staged[limit_us - 1];
3181        Some(NodesCursor {
3182            ts_ms: last.sort_ts_ms,
3183            id: last.sort_id.clone(),
3184        })
3185    } else {
3186        None
3187    };
3188    staged.truncate(limit_us);
3189
3190    let next_cursor_str = match next_cursor {
3191        Some(c) => Some(encode_cursor(&c)?),
3192        None => None,
3193    };
3194
3195    let nodes: Vec<GraphNode> = staged.into_iter().map(|s| s.node).collect();
3196    let payload = GraphNodesResponse {
3197        nodes,
3198        next_cursor: next_cursor_str,
3199    };
3200
3201    // Attach the entity-cap header so clients can show truncation UX
3202    // without parsing the body.
3203    let mut response = Json(payload).into_response();
3204    if cap_reached {
3205        response
3206            .headers_mut()
3207            .insert(ENTITY_CAP_HEADER, HeaderValue::from_static("true"));
3208    }
3209    Ok(response)
3210}
3211
3212// --- /v1/graph/edges --------------------------------------------------
3213
3214#[derive(Debug)]
3215struct StagingEdge {
3216    edge: GraphEdge,
3217    kind_idx: u8,
3218    sub_id: String,
3219}
3220
3221fn cmp_edge_sort_keys(a: (u8, &str), b: (u8, &str)) -> std::cmp::Ordering {
3222    match a.0.cmp(&b.0) {
3223        std::cmp::Ordering::Equal => a.1.cmp(b.1),
3224        other => other,
3225    }
3226}
3227
3228fn edge_passes_cursor(kind_idx: u8, sub_id: &str, cursor: &EdgesCursor) -> bool {
3229    cmp_edge_sort_keys((kind_idx, sub_id), (cursor.kind_idx, cursor.sub_id.as_str()))
3230        == std::cmp::Ordering::Greater
3231}
3232
3233/// Whether the supplied focus `node_id` (kind, value) matches an edge's
3234/// (source, target) endpoint pair under a given edge kind. Used to
3235/// filter `?node_id=...` queries.
3236fn edge_touches_focus(
3237    kind: EdgeKind,
3238    focus_kind: NodeKind,
3239    focus_value: &str,
3240    src_value: &str,
3241    tgt_value: &str,
3242    extra_value: Option<&str>,
3243) -> bool {
3244    // Determine which endpoint kinds this edge family produces; if the
3245    // focus kind isn't compatible, no match.
3246    match kind {
3247        EdgeKind::Triple => match focus_kind {
3248            // Triple edges flow source_episode → ent:<object_id>. We
3249            // also expose subject/object entities as endpoints (see
3250            // emit_triple_edges_for_focus); the matching here covers
3251            // episode focus + entity focus + the symmetric pair.
3252            NodeKind::Episode => src_value == focus_value,
3253            NodeKind::Entity => {
3254                tgt_value == focus_value
3255                    || extra_value.map(|x| x == focus_value).unwrap_or(false)
3256                    || src_value == focus_value
3257            }
3258            _ => false,
3259        },
3260        EdgeKind::DocumentChunk => match focus_kind {
3261            NodeKind::Document => src_value == focus_value,
3262            NodeKind::Chunk => tgt_value == focus_value,
3263            _ => false,
3264        },
3265        EdgeKind::ClusterMember => match focus_kind {
3266            NodeKind::Cluster => src_value == focus_value,
3267            NodeKind::Episode => tgt_value == focus_value,
3268            _ => false,
3269        },
3270    }
3271}
3272
3273#[derive(Debug)]
3274struct EdgeRowTriple {
3275    triple_id: String,
3276    source_memory_id: Option<String>,
3277    object_id: String,
3278    predicate: String,
3279    confidence: f32,
3280}
3281
3282fn fetch_triple_edges(conn: &rusqlite::Connection) -> rusqlite::Result<Vec<EdgeRowTriple>> {
3283    // Emit one edge per triple: source_episode → ent:object_id. Skip
3284    // orphan triples (`source_episode_id IS NULL`). Bound the scan at
3285    // GRAPH_EDGES_MAX_LIMIT * a safety multiplier so a runaway tenant
3286    // doesn't OOM the page-builder; the merge-and-page step trims to
3287    // the real limit downstream.
3288    let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3289    let mut stmt = conn.prepare(
3290        "SELECT t.triple_id, e.memory_id, t.object_id, t.predicate, t.confidence
3291           FROM triples t
3292           LEFT JOIN episodes e ON e.rowid = t.source_episode_id
3293          WHERE t.status = 'active'
3294          ORDER BY t.triple_id ASC
3295          LIMIT ?1",
3296    )?;
3297    let rows: Vec<EdgeRowTriple> = stmt
3298        .query_map(rusqlite::params![safety_cap], |r| {
3299            Ok(EdgeRowTriple {
3300                triple_id: r.get(0)?,
3301                source_memory_id: r.get::<_, Option<String>>(1)?,
3302                object_id: r.get(2)?,
3303                predicate: r.get(3)?,
3304                confidence: r.get(4)?,
3305            })
3306        })?
3307        .collect::<rusqlite::Result<Vec<_>>>()?;
3308    Ok(rows)
3309}
3310
3311#[derive(Debug)]
3312struct EdgeRowDocChunk {
3313    chunk_id: String,
3314    doc_id: String,
3315}
3316
3317fn fetch_document_chunk_edges(
3318    conn: &rusqlite::Connection,
3319) -> rusqlite::Result<Vec<EdgeRowDocChunk>> {
3320    let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3321    let mut stmt = conn.prepare(
3322        "SELECT c.chunk_id, c.doc_id
3323           FROM document_chunks c
3324           JOIN documents d ON d.doc_id = c.doc_id
3325          WHERE d.status = 'active'
3326          ORDER BY c.chunk_id ASC
3327          LIMIT ?1",
3328    )?;
3329    let rows: Vec<EdgeRowDocChunk> = stmt
3330        .query_map(rusqlite::params![safety_cap], |r| {
3331            Ok(EdgeRowDocChunk {
3332                chunk_id: r.get(0)?,
3333                doc_id: r.get(1)?,
3334            })
3335        })?
3336        .collect::<rusqlite::Result<Vec<_>>>()?;
3337    Ok(rows)
3338}
3339
3340#[derive(Debug)]
3341struct EdgeRowClusterMember {
3342    cluster_id: String,
3343    memory_id: String,
3344}
3345
3346fn fetch_cluster_member_edges(
3347    conn: &rusqlite::Connection,
3348) -> rusqlite::Result<Vec<EdgeRowClusterMember>> {
3349    let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3350    let mut stmt = conn.prepare(
3351        "SELECT ce.cluster_id, ce.memory_id
3352           FROM cluster_episodes ce
3353           JOIN episodes e ON e.memory_id = ce.memory_id
3354          WHERE e.status = 'active'
3355          ORDER BY ce.cluster_id ASC, ce.memory_id ASC
3356          LIMIT ?1",
3357    )?;
3358    let rows: Vec<EdgeRowClusterMember> = stmt
3359        .query_map(rusqlite::params![safety_cap], |r| {
3360            Ok(EdgeRowClusterMember {
3361                cluster_id: r.get(0)?,
3362                memory_id: r.get(1)?,
3363            })
3364        })?
3365        .collect::<rusqlite::Result<Vec<_>>>()?;
3366    Ok(rows)
3367}
3368
3369/// `GET /v1/graph/edges`. Paginated edge catalog. See module-level
3370/// comments for the contract.
3371async fn graph_edges_handler(
3372    TenantExtractor(tenant): TenantExtractor,
3373    Query(q): Query<GraphEdgesQuery>,
3374) -> Result<Json<GraphEdgesResponse>, ApiError> {
3375    let limit = q.limit.unwrap_or(GRAPH_EDGES_DEFAULT_LIMIT);
3376    let limit = limit.clamp(1, GRAPH_EDGES_MAX_LIMIT);
3377    let kinds = parse_edge_kind_filter(q.r#type.as_deref())?;
3378    let cursor = match q.cursor.as_deref() {
3379        None => None,
3380        Some("") => None,
3381        Some(raw) => Some(decode_cursor::<EdgesCursor>(raw)?),
3382    };
3383
3384    let focus = match q.node_id.as_deref() {
3385        None => None,
3386        Some(raw) => {
3387            let (kind, value) = parse_node_id(raw)?;
3388            Some((kind, value.to_string()))
3389        }
3390    };
3391
3392    let want_triple = kinds.contains(&EdgeKind::Triple);
3393    let want_doc_chunk = kinds.contains(&EdgeKind::DocumentChunk);
3394    let want_cluster_member = kinds.contains(&EdgeKind::ClusterMember);
3395
3396    let staged: Vec<StagingEdge> = tenant
3397        .read()
3398        .interact(move |conn| {
3399            let mut staged: Vec<StagingEdge> = Vec::new();
3400
3401            if want_triple {
3402                for t in fetch_triple_edges(conn)? {
3403                    let src_id = match &t.source_memory_id {
3404                        Some(mid) => format!("ep:{mid}"),
3405                        None => continue, // orphan triple — skip
3406                    };
3407                    let tgt_id = format!("ent:{}", t.object_id);
3408                    if let Some((fk, fv)) = &focus {
3409                        // `src_value` for matching is the bare memory_id
3410                        // (after the `ep:` prefix); `tgt_value` is the
3411                        // bare entity value.
3412                        if !edge_touches_focus(
3413                            EdgeKind::Triple,
3414                            *fk,
3415                            fv,
3416                            t.source_memory_id
3417                                .as_deref()
3418                                .unwrap_or(""),
3419                            &t.object_id,
3420                            // Triples carry a subject_id too, but the
3421                            // emitted edge only goes ep → ent(object).
3422                            // For entity-focus matches we also accept
3423                            // hits on subject_id; surface it through
3424                            // the `extra` slot.
3425                            None,
3426                        ) {
3427                            continue;
3428                        }
3429                    }
3430                    let edge = GraphEdge {
3431                        id: edge_id(&src_id, "triple", &tgt_id),
3432                        source: src_id,
3433                        target: tgt_id,
3434                        kind: "triple",
3435                        predicate: Some(t.predicate),
3436                        weight: Some(t.confidence),
3437                    };
3438                    staged.push(StagingEdge {
3439                        edge,
3440                        kind_idx: EdgeKind::Triple.order_idx(),
3441                        sub_id: t.triple_id,
3442                    });
3443                }
3444            }
3445            if want_doc_chunk {
3446                for dc in fetch_document_chunk_edges(conn)? {
3447                    let src_id = format!("doc:{}", dc.doc_id);
3448                    let tgt_id = format!("chunk:{}", dc.chunk_id);
3449                    if let Some((fk, fv)) = &focus {
3450                        if !edge_touches_focus(
3451                            EdgeKind::DocumentChunk,
3452                            *fk,
3453                            fv,
3454                            &dc.doc_id,
3455                            &dc.chunk_id,
3456                            None,
3457                        ) {
3458                            continue;
3459                        }
3460                    }
3461                    let edge = GraphEdge {
3462                        id: edge_id(&src_id, "document_chunk", &tgt_id),
3463                        source: src_id,
3464                        target: tgt_id,
3465                        kind: "document_chunk",
3466                        predicate: None,
3467                        weight: None,
3468                    };
3469                    staged.push(StagingEdge {
3470                        edge,
3471                        kind_idx: EdgeKind::DocumentChunk.order_idx(),
3472                        sub_id: dc.chunk_id,
3473                    });
3474                }
3475            }
3476            if want_cluster_member {
3477                for cm in fetch_cluster_member_edges(conn)? {
3478                    let src_id = format!("cl:{}", cm.cluster_id);
3479                    let tgt_id = format!("ep:{}", cm.memory_id);
3480                    if let Some((fk, fv)) = &focus {
3481                        if !edge_touches_focus(
3482                            EdgeKind::ClusterMember,
3483                            *fk,
3484                            fv,
3485                            &cm.cluster_id,
3486                            &cm.memory_id,
3487                            None,
3488                        ) {
3489                            continue;
3490                        }
3491                    }
3492                    let edge = GraphEdge {
3493                        id: edge_id(&src_id, "cluster_member", &tgt_id),
3494                        source: src_id,
3495                        target: tgt_id,
3496                        kind: "cluster_member",
3497                        predicate: None,
3498                        weight: None,
3499                    };
3500                    let sub_id = format!("{}\u{1f}{}", cm.cluster_id, cm.memory_id);
3501                    staged.push(StagingEdge {
3502                        edge,
3503                        kind_idx: EdgeKind::ClusterMember.order_idx(),
3504                        sub_id,
3505                    });
3506                }
3507            }
3508            Ok::<_, rusqlite::Error>(staged)
3509        })
3510        .await
3511        .map_err(ApiError::from)?;
3512
3513    // Apply cursor filter.
3514    let mut staged = staged;
3515    if let Some(cur) = &cursor {
3516        staged.retain(|s| edge_passes_cursor(s.kind_idx, &s.sub_id, cur));
3517    }
3518
3519    // Sort `(kind_idx ASC, sub_id ASC)` — stable, simple.
3520    staged.sort_by(|a, b| {
3521        cmp_edge_sort_keys((a.kind_idx, &a.sub_id), (b.kind_idx, &b.sub_id))
3522    });
3523
3524    let limit_us = limit as usize;
3525    let next_cursor = if staged.len() > limit_us {
3526        let last = &staged[limit_us - 1];
3527        Some(EdgesCursor {
3528            kind_idx: last.kind_idx,
3529            sub_id: last.sub_id.clone(),
3530        })
3531    } else {
3532        None
3533    };
3534    staged.truncate(limit_us);
3535    let next_cursor_str = match next_cursor {
3536        Some(c) => Some(encode_cursor(&c)?),
3537        None => None,
3538    };
3539
3540    let edges: Vec<GraphEdge> = staged.into_iter().map(|s| s.edge).collect();
3541    Ok(Json(GraphEdgesResponse {
3542        edges,
3543        next_cursor: next_cursor_str,
3544    }))
3545}
3546
3547// ---------------------------------------------------------------------------
3548// Graph inspect — kind-discriminated full-record drill (v0.10.0)
3549//
3550// `GET /v1/graph/inspect/{id}` powers solo-web's right-side inspector
3551// panel. Path `id` carries the prefixed node identifier (ep:/doc:/chunk:/
3552// cl:/ent:); the handler dispatches per-kind and returns the same wire
3553// shape solo-web's `InspectResponse` expects: `{ node, full_text?,
3554// triples_in[], triples_out[] }`.
3555//
3556// Per-kind contract (v0.10.0 P1):
3557//   * `ep:<memory_id>`     full_text = episodes.content (untruncated),
3558//                          triples_in = [],
3559//                          triples_out = triples WHERE source_episode_id = rowid
3560//                          (one edge per triple, ep -> ent(object), predicate
3561//                          + weight surfaced). Episodes never appear as triple
3562//                          subjects/objects, so triples_in is structurally
3563//                          empty.
3564//   * `doc:<doc_id>`       full_text = concatenated chunk bodies separated by
3565//                          "\n\n" (no `documents.full_text` column exists; the
3566//                          chunks-concat path produces the same final text the
3567//                          ingester chunked from). triples_in/out = [] --
3568//                          documents don't directly carry triples; their
3569//                          chunks transitively do, but the inspector reaches
3570//                          those via the existing `/v1/graph/expand` drill.
3571//   * `chunk:<chunk_id>`   full_text = document_chunks.content,
3572//                          triples_in/out = [] (chunks aren't triple endpoints).
3573//   * `cl:<cluster_id>`    full_text = label + "\n\n" + abstraction
3574//                          (`semantic_abstractions.content`) when an
3575//                          abstraction exists; just the label otherwise.
3576//                          triples_in/out = [].
3577//   * `ent:<value>`        full_text = None (entities have no body),
3578//                          triples_in = [],
3579//                          triples_out = all triples where the entity appears
3580//                          as subject OR object. Capped at
3581//                          `GRAPH_INSPECT_ENTITY_TRIPLES_CAP` (50). Entities
3582//                          are synthetic -- an `ent:<value>` with zero triples
3583//                          in the tenant returns 404 (the entity exists only
3584//                          if at least one triple references it).
3585//
3586// Error semantics: 404 if the prefixed id has no row in the tenant's DB.
3587// 400 if the prefix is unknown or the body after `:` is empty (reuses
3588// `parse_node_id`). Tenant + auth are handled by the existing extractors.
3589//
3590// Lesson #30: no audit emit. Inspect is a derived read over already-
3591// audited primitives.
3592// ---------------------------------------------------------------------------
3593
3594/// Cap on triples returned for an entity inspect. Entities can be heavily
3595/// referenced ("user", "Alice"); the inspector panel only needs enough
3596/// for orientation. The `/v1/graph/expand?kind=triple` path delivers the
3597/// paginated full set when the UI needs more.
3598const GRAPH_INSPECT_ENTITY_TRIPLES_CAP: i64 = 50;
3599
3600#[derive(Debug, Serialize)]
3601struct GraphInspectResponse {
3602    node: GraphNode,
3603    #[serde(skip_serializing_if = "Option::is_none")]
3604    full_text: Option<String>,
3605    triples_in: Vec<GraphEdge>,
3606    triples_out: Vec<GraphEdge>,
3607}
3608
3609/// `GET /v1/graph/inspect/{id}`. See module-level comments.
3610async fn graph_inspect_handler(
3611    TenantExtractor(tenant): TenantExtractor,
3612    Path(id): Path<String>,
3613) -> Result<Json<GraphInspectResponse>, ApiError> {
3614    let (kind, value) = parse_node_id(&id)?;
3615    let tenant_id_str = tenant.tenant_id().to_string();
3616    let value = value.to_string();
3617    let node_id_full = id;
3618    match kind {
3619        NodeKind::Episode => {
3620            inspect_episode_node(&tenant, &tenant_id_str, value, node_id_full).await
3621        }
3622        NodeKind::Document => {
3623            inspect_document_node(&tenant, &tenant_id_str, value, node_id_full).await
3624        }
3625        NodeKind::Chunk => {
3626            inspect_chunk_node(&tenant, &tenant_id_str, value, node_id_full).await
3627        }
3628        NodeKind::Cluster => {
3629            inspect_cluster_node(&tenant, &tenant_id_str, value, node_id_full).await
3630        }
3631        NodeKind::Entity => {
3632            inspect_entity_node(&tenant, &tenant_id_str, value, node_id_full).await
3633        }
3634    }
3635    .map(Json)
3636}
3637
3638// ---- per-kind paths ----
3639
3640async fn inspect_episode_node(
3641    tenant: &TenantHandle,
3642    tenant_id: &str,
3643    memory_id: String,
3644    node_id_full: String,
3645) -> Result<GraphInspectResponse, ApiError> {
3646    let memory_id_for_err = memory_id.clone();
3647    let memory_id_q = memory_id.clone();
3648    // Fetch the episode row + all triples sourced from it in one
3649    // interact() call to keep the connection check-out short.
3650    let fetched: Option<(ExpandedEpisode, Vec<TripleRow>)> = tenant
3651        .read()
3652        .interact(move |conn| {
3653            let ep_row: Option<(i64, i64, String)> = conn
3654                .query_row(
3655                    "SELECT rowid, ts_ms, content
3656                       FROM episodes
3657                      WHERE memory_id = ?1
3658                        AND status = 'active'",
3659                    rusqlite::params![&memory_id_q],
3660                    |r| {
3661                        Ok((
3662                            r.get::<_, i64>(0)?,
3663                            r.get::<_, i64>(1)?,
3664                            r.get::<_, String>(2)?,
3665                        ))
3666                    },
3667                )
3668                .map(Some)
3669                .or_else(|e| match e {
3670                    rusqlite::Error::QueryReturnedNoRows => Ok(None),
3671                    other => Err(other),
3672                })?;
3673            let Some((rowid, ts_ms, content)) = ep_row else {
3674                return Ok(None);
3675            };
3676            let mut stmt = conn.prepare(
3677                "SELECT subject_id, predicate, object_id, confidence
3678                   FROM triples
3679                  WHERE source_episode_id = ?1
3680                    AND status = 'active'
3681                  ORDER BY valid_from_ms DESC",
3682            )?;
3683            let triples = stmt
3684                .query_map(rusqlite::params![rowid], |r| {
3685                    Ok(TripleRow {
3686                        subject_id: r.get(0)?,
3687                        predicate: r.get(1)?,
3688                        object_id: r.get(2)?,
3689                        confidence: r.get(3)?,
3690                    })
3691                })?
3692                .collect::<rusqlite::Result<Vec<_>>>()?;
3693            let ep = ExpandedEpisode {
3694                memory_id: memory_id_q,
3695                ts_ms,
3696                content,
3697            };
3698            Ok::<_, rusqlite::Error>(Some((ep, triples)))
3699        })
3700        .await
3701        .map_err(ApiError::from)?;
3702
3703    let (ep, triples) = fetched.ok_or_else(|| {
3704        ApiError::not_found(format!(
3705            "node_id {node_id_full:?} (memory_id {memory_id_for_err}) not found in current tenant"
3706        ))
3707    })?;
3708
3709    let node = graph_node_for_episode(tenant_id, &ep);
3710    let full_text = Some(ep.content.clone());
3711    // Triples flow from this episode (the source) to entity endpoints.
3712    // Emit one edge per triple: ep -> ent(object), predicate from the
3713    // triple, weight = confidence. This mirrors the `/v1/graph/edges`
3714    // triple-edge convention so the renderer can dedupe via composite id.
3715    let mut triples_out = Vec::with_capacity(triples.len());
3716    for t in triples {
3717        let tgt_id = format!("ent:{}", t.object_id);
3718        triples_out.push(GraphEdge {
3719            id: edge_id(&node_id_full, "triple", &tgt_id),
3720            source: node_id_full.clone(),
3721            target: tgt_id,
3722            kind: "triple",
3723            predicate: Some(t.predicate),
3724            weight: Some(t.confidence),
3725        });
3726    }
3727    Ok(GraphInspectResponse {
3728        node,
3729        full_text,
3730        triples_in: Vec::new(),
3731        triples_out,
3732    })
3733}
3734
3735async fn inspect_document_node(
3736    tenant: &TenantHandle,
3737    tenant_id: &str,
3738    doc_id: String,
3739    node_id_full: String,
3740) -> Result<GraphInspectResponse, ApiError> {
3741    let doc_id_for_err = doc_id.clone();
3742    let doc_id_q = doc_id.clone();
3743    // Fetch the document row + all chunk bodies (ORDER BY chunk_index) in
3744    // one interact() call. The chunks-concat path is the source of full_text
3745    // since the `documents` table doesn't carry the original raw text. For
3746    // v0.10.0 P1 we concatenate every chunk; pagination is the inspector
3747    // panel's responsibility if the document is very large.
3748    let fetched: Option<(ExpandedDocument, Vec<String>)> = tenant
3749        .read()
3750        .interact(move |conn| {
3751            let doc_row: Option<ExpandedDocument> = conn
3752                .query_row(
3753                    "SELECT doc_id, title, source, ingested_at_ms
3754                       FROM documents
3755                      WHERE doc_id = ?1
3756                        AND status = 'active'",
3757                    rusqlite::params![&doc_id_q],
3758                    |r| {
3759                        Ok(ExpandedDocument {
3760                            doc_id: r.get(0)?,
3761                            title: r.get(1)?,
3762                            source: r.get(2)?,
3763                            ingested_at_ms: r.get(3)?,
3764                        })
3765                    },
3766                )
3767                .map(Some)
3768                .or_else(|e| match e {
3769                    rusqlite::Error::QueryReturnedNoRows => Ok(None),
3770                    other => Err(other),
3771                })?;
3772            let Some(doc) = doc_row else {
3773                return Ok(None);
3774            };
3775            let mut stmt = conn.prepare(
3776                "SELECT content
3777                   FROM document_chunks
3778                  WHERE doc_id = ?1
3779                  ORDER BY chunk_index ASC",
3780            )?;
3781            let chunks = stmt
3782                .query_map(rusqlite::params![&doc_id_q], |r| r.get::<_, String>(0))?
3783                .collect::<rusqlite::Result<Vec<_>>>()?;
3784            Ok::<_, rusqlite::Error>(Some((doc, chunks)))
3785        })
3786        .await
3787        .map_err(ApiError::from)?;
3788
3789    let (doc, chunks) = fetched.ok_or_else(|| {
3790        ApiError::not_found(format!(
3791            "node_id {node_id_full:?} (doc_id {doc_id_for_err}) not found in current tenant"
3792        ))
3793    })?;
3794
3795    let full_text = if chunks.is_empty() {
3796        // Document with zero chunks (e.g. mid-ingest, or an empty source).
3797        // Return None to signal "no body available" rather than an empty
3798        // string -- saves the renderer a degenerate code path.
3799        None
3800    } else {
3801        Some(chunks.join("\n\n"))
3802    };
3803
3804    Ok(GraphInspectResponse {
3805        node: graph_node_for_document(tenant_id, &doc),
3806        full_text,
3807        triples_in: Vec::new(),
3808        triples_out: Vec::new(),
3809    })
3810}
3811
3812async fn inspect_chunk_node(
3813    tenant: &TenantHandle,
3814    tenant_id: &str,
3815    chunk_id: String,
3816    node_id_full: String,
3817) -> Result<GraphInspectResponse, ApiError> {
3818    let chunk_id_for_err = chunk_id.clone();
3819    let chunk_id_q = chunk_id.clone();
3820    let row: Option<(ExpandedChunk, i64)> = tenant
3821        .read()
3822        .interact(move |conn| {
3823            conn.query_row(
3824                "SELECT c.chunk_id, c.chunk_index, c.content, c.created_at_ms
3825                   FROM document_chunks c
3826                   JOIN documents d ON d.doc_id = c.doc_id
3827                  WHERE c.chunk_id = ?1
3828                    AND d.status = 'active'",
3829                rusqlite::params![&chunk_id_q],
3830                |r| {
3831                    Ok((
3832                        ExpandedChunk {
3833                            chunk_id: r.get(0)?,
3834                            chunk_index: r.get(1)?,
3835                            content: r.get(2)?,
3836                        },
3837                        r.get::<_, i64>(3)?,
3838                    ))
3839                },
3840            )
3841            .map(Some)
3842            .or_else(|e| match e {
3843                rusqlite::Error::QueryReturnedNoRows => Ok(None),
3844                other => Err(other),
3845            })
3846        })
3847        .await
3848        .map_err(ApiError::from)?;
3849
3850    let (chunk, created_at_ms) = row.ok_or_else(|| {
3851        ApiError::not_found(format!(
3852            "node_id {node_id_full:?} (chunk_id {chunk_id_for_err}) not found in current tenant"
3853        ))
3854    })?;
3855
3856    let full_text = Some(chunk.content.clone());
3857    let mut node = graph_node_for_chunk(tenant_id, &chunk);
3858    // Mirror the `/v1/graph/nodes` chunk-row behaviour: surface
3859    // `created_at_ms` so the inspector panel has a sortable timestamp.
3860    node.ts_ms = Some(created_at_ms);
3861
3862    Ok(GraphInspectResponse {
3863        node,
3864        full_text,
3865        triples_in: Vec::new(),
3866        triples_out: Vec::new(),
3867    })
3868}
3869
3870async fn inspect_cluster_node(
3871    tenant: &TenantHandle,
3872    tenant_id: &str,
3873    cluster_id: String,
3874    node_id_full: String,
3875) -> Result<GraphInspectResponse, ApiError> {
3876    let cluster_id_for_err = cluster_id.clone();
3877    let cluster_id_q = cluster_id.clone();
3878    let row: Option<(Option<String>, i64)> = tenant
3879        .read()
3880        .interact(move |conn| {
3881            conn.query_row(
3882                "SELECT sa.content, c.created_at_ms
3883                   FROM clusters c
3884                   LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
3885                  WHERE c.cluster_id = ?1",
3886                rusqlite::params![&cluster_id_q],
3887                |r| Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?)),
3888            )
3889            .map(Some)
3890            .or_else(|e| match e {
3891                rusqlite::Error::QueryReturnedNoRows => Ok(None),
3892                other => Err(other),
3893            })
3894        })
3895        .await
3896        .map_err(ApiError::from)?;
3897
3898    let (abstraction, created_at_ms) = row.ok_or_else(|| {
3899        ApiError::not_found(format!(
3900            "node_id {node_id_full:?} (cluster_id {cluster_id_for_err}) not found in current tenant"
3901        ))
3902    })?;
3903
3904    // full_text is "<cluster_id label>\n\n<abstraction>" when an abstraction
3905    // exists; just the label otherwise. Brief "cluster" -- the cluster
3906    // label is `clusters.cluster_id` (the user-facing label is the
3907    // abstraction; clusters don't have a `label` column).
3908    let full_text = match abstraction.as_deref() {
3909        Some(a) => Some(format!("cluster {cluster_id_for_err}\n\n{a}")),
3910        None => Some(format!("cluster {cluster_id_for_err}")),
3911    };
3912
3913    Ok(GraphInspectResponse {
3914        node: graph_node_for_cluster(
3915            tenant_id,
3916            &cluster_id_for_err,
3917            abstraction.as_deref(),
3918            created_at_ms,
3919        ),
3920        full_text,
3921        triples_in: Vec::new(),
3922        triples_out: Vec::new(),
3923    })
3924}
3925
3926async fn inspect_entity_node(
3927    tenant: &TenantHandle,
3928    tenant_id: &str,
3929    entity_value: String,
3930    node_id_full: String,
3931) -> Result<GraphInspectResponse, ApiError> {
3932    // Entities are synthetic. They "exist" only if at least one triple
3933    // references them as subject or object. Zero triples -> 404 per brief.
3934    let entity_q = entity_value.clone();
3935    let rows: Vec<TripleRow> = tenant
3936        .read()
3937        .interact(move |conn| {
3938            let mut stmt = conn.prepare(
3939                "SELECT subject_id, predicate, object_id, confidence
3940                   FROM triples
3941                  WHERE (subject_id = ?1 OR object_id = ?1)
3942                    AND status = 'active'
3943                  ORDER BY valid_from_ms DESC
3944                  LIMIT ?2",
3945            )?;
3946            stmt.query_map(
3947                rusqlite::params![&entity_q, GRAPH_INSPECT_ENTITY_TRIPLES_CAP],
3948                |r| {
3949                    Ok(TripleRow {
3950                        subject_id: r.get(0)?,
3951                        predicate: r.get(1)?,
3952                        object_id: r.get(2)?,
3953                        confidence: r.get(3)?,
3954                    })
3955                },
3956            )?
3957            .collect::<rusqlite::Result<Vec<_>>>()
3958        })
3959        .await
3960        .map_err(ApiError::from)?;
3961
3962    if rows.is_empty() {
3963        return Err(ApiError::not_found(format!(
3964            "node_id {node_id_full:?} (entity {entity_value:?}) not found in current tenant -- entities must be referenced by at least one triple to be inspectable"
3965        )));
3966    }
3967
3968    // Triples flow out FROM the entity to its counterpart. For each row
3969    // determine which side the entity appears on and emit ent:<self> ->
3970    // ent:<other>. Brief calls these triples_out (entities don't have
3971    // structural triples_in in v0.10.0 P1).
3972    let mut triples_out = Vec::with_capacity(rows.len());
3973    for t in rows {
3974        let other = if t.subject_id == entity_value {
3975            t.object_id
3976        } else {
3977            // entity_value matched on object_id; counterpart is subject.
3978            t.subject_id
3979        };
3980        let tgt_id = format!("ent:{other}");
3981        triples_out.push(GraphEdge {
3982            id: edge_id(&node_id_full, "triple", &tgt_id),
3983            source: node_id_full.clone(),
3984            target: tgt_id,
3985            kind: "triple",
3986            predicate: Some(t.predicate),
3987            weight: Some(t.confidence),
3988        });
3989    }
3990
3991    Ok(GraphInspectResponse {
3992        node: graph_node_for_entity(tenant_id, &entity_value),
3993        full_text: None,
3994        triples_in: Vec::new(),
3995        triples_out,
3996    })
3997}
3998
3999// ---------------------------------------------------------------------------
4000// Graph neighbors -- unified explicit + HNSW-semantic (v0.10.0)
4001//
4002// `GET /v1/graph/neighbors/{id}` powers solo-web's "show similar" overlay.
4003// Returns the same `GraphResponse { nodes, edges }` envelope as the rest of
4004// the family, combining:
4005//
4006//   * Explicit edges (triples / document_chunk / cluster_member) incident
4007//     to the focal node -- the same shape `/v1/graph/expand` produces for
4008//     a given (node_id, edge_kind) pair, but UNIONed across every edge kind
4009//     compatible with the focal node's kind.
4010//
4011//   * HNSW-semantic edges (cosine-similarity neighbors) -- only valid for
4012//     `ep:` (episodes) and `chunk:` (chunks); other source kinds return
4013//     400 when `kind=semantic` is requested alone, or are silently skipped
4014//     when `kind=both` is requested (explicit-only path still runs).
4015//
4016// Why this isn't just expand-with-a-flag: `/v1/graph/expand` takes a
4017// specific `kind=<edge-kind>` parameter and expands along ONE edge kind at
4018// a time. `/v1/graph/neighbors/:id` UNIFIES all compatible edge kinds
4019// incident to the focal node into one response. Different UX (drill vs.
4020// overview); different API; both needed.
4021//
4022// ## Refactor decision
4023//
4024// The brief recommends extracting `expand`'s per-kind helpers into a
4025// shared module. In practice the `expand_*` async fns already do exactly
4026// what neighbors needs for the explicit path (same response shape, same
4027// tenant + auth + existence semantics). To keep the change surgical and
4028// to preserve `expand`'s existing tests byte-for-byte, neighbors **reuses
4029// the existing `expand_*` async fns directly** rather than refactoring
4030// their bodies. The explicit path is a thin orchestrator that calls every
4031// `expand_*` fn compatible with the focal node's kind and concatenates
4032// the results.
4033//
4034// ## Dedup rule (kind=both)
4035//
4036// When an edge with the same (source, target) appears in BOTH the
4037// explicit and the semantic result sets, the explicit edge wins -- the
4038// semantic edge is dropped. We dedupe by `(source, target)` (NOT by full
4039// edge id, which encodes the kind too): the rule "explicit beats
4040// semantic" only makes sense when both endpoints agree, regardless of
4041// kind. In practice this is most likely to fire when an entity-focused
4042// expand (which surfaces episodes as triple-targets) collides with a
4043// semantic search hit on the same episode pair.
4044//
4045// ## Limit policy
4046//
4047// `limit` is applied PER KIND, not total. With `limit=25` and
4048// `kind=both`, the response carries up to 25 explicit + 25 semantic
4049// edges (minus dedupe). Silent clamp at 100 (matches the rest of the
4050// `/v1/graph/*` family).
4051//
4052// ## Threshold filter
4053//
4054// `threshold` (default 0.75) filters semantic neighbors by
4055// `weight >= threshold`, where `weight = (1 - cos_distance).max(0)`. The
4056// default is conservative -- below 0.75 the renderer typically shows too
4057// many spurious edges for a useful "show similar" overlay. Callers can
4058// dial down (e.g. `?threshold=0.5`) for a broader view.
4059//
4060// See `docs/dev-log/0116-graph-neighbors-impl.md` for the design notes.
4061// ---------------------------------------------------------------------------
4062
4063/// Default page size when the caller omits `?limit=`. Conservative so the
4064/// "show similar" overlay isn't visually overwhelming on first click.
4065const GRAPH_NEIGHBORS_DEFAULT_LIMIT: u32 = 25;
4066/// Silent clamp ceiling. Matches the rest of the `/v1/graph/*` family.
4067const GRAPH_NEIGHBORS_MAX_LIMIT: u32 = 100;
4068/// Conservative similarity floor. Edges with `weight < threshold` are
4069/// dropped from the semantic result set.
4070const GRAPH_NEIGHBORS_DEFAULT_THRESHOLD: f32 = 0.75;
4071
4072/// Discriminator for which neighbor kinds the caller wants. Default is
4073/// `both` (explicit edges + HNSW-semantic).
4074#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
4075#[serde(rename_all = "snake_case")]
4076enum GraphNeighborsKind {
4077    Explicit,
4078    Semantic,
4079    #[default]
4080    Both,
4081}
4082
4083#[derive(Debug, Deserialize)]
4084struct GraphNeighborsQuery {
4085    #[serde(default)]
4086    kind: Option<GraphNeighborsKind>,
4087    #[serde(default)]
4088    threshold: Option<f32>,
4089    #[serde(default)]
4090    limit: Option<u32>,
4091}
4092
4093/// `GET /v1/graph/neighbors/{id}`. See module-level comments.
4094async fn graph_neighbors_handler(
4095    TenantExtractor(tenant): TenantExtractor,
4096    Path(id): Path<String>,
4097    Query(q): Query<GraphNeighborsQuery>,
4098) -> Result<Json<GraphExpandResponse>, ApiError> {
4099    let kind = q.kind.unwrap_or_default();
4100    let threshold = q.threshold.unwrap_or(GRAPH_NEIGHBORS_DEFAULT_THRESHOLD);
4101    if !(0.0..=1.0).contains(&threshold) {
4102        return Err(ApiError::bad_request(format!(
4103            "threshold must be in [0.0, 1.0]; got {threshold}"
4104        )));
4105    }
4106    // Silent clamp at GRAPH_NEIGHBORS_MAX_LIMIT -- matches expand /
4107    // nodes / edges convention. Test `neighbors_limit_clamped_at_100`
4108    // locks in the clamp policy.
4109    let limit_raw = q.limit.unwrap_or(GRAPH_NEIGHBORS_DEFAULT_LIMIT);
4110    let limit = limit_raw.clamp(1, GRAPH_NEIGHBORS_MAX_LIMIT);
4111
4112    let (node_kind, value) = parse_node_id(&id)?;
4113    let value_owned = value.to_string();
4114    let tenant_id_str = tenant.tenant_id().to_string();
4115    let node_id_full = id;
4116
4117    // Existence probe for the focal node. The explicit + semantic paths
4118    // each handle "node-found-but-zero-neighbors" gracefully (200 with
4119    // empty arrays) -- but we want a true 404 when the id resolves to no
4120    // row at all, regardless of which kind the caller asked for. This
4121    // matches the inspect endpoint's gate: a node has to exist to be
4122    // meaningfully "neighborable".
4123    ensure_neighbors_focal_exists(&tenant, node_kind, &value_owned, &node_id_full).await?;
4124
4125    // Dispatch.
4126    let (explicit_nodes, explicit_edges) = if matches!(
4127        kind,
4128        GraphNeighborsKind::Explicit | GraphNeighborsKind::Both
4129    ) {
4130        neighbors_explicit(
4131            &tenant,
4132            &tenant_id_str,
4133            node_kind,
4134            &value_owned,
4135            &node_id_full,
4136            limit as i64,
4137        )
4138        .await?
4139    } else {
4140        (Vec::new(), Vec::new())
4141    };
4142
4143    let (semantic_nodes, semantic_edges) = if matches!(
4144        kind,
4145        GraphNeighborsKind::Semantic | GraphNeighborsKind::Both
4146    ) {
4147        match neighbors_semantic(
4148            &tenant,
4149            &tenant_id_str,
4150            node_kind,
4151            &value_owned,
4152            &node_id_full,
4153            limit,
4154            threshold,
4155        )
4156        .await
4157        {
4158            Ok(parts) => parts,
4159            Err(e) => {
4160                // `kind=semantic` alone against an unsupported focal node
4161                // (doc/cl/ent) is a hard 400 -- the caller asked for ONLY
4162                // semantic neighbors and there are none possible.
4163                //
4164                // `kind=both` against an unsupported focal node silently
4165                // skips the semantic step; the explicit path still
4166                // delivers a meaningful answer. This mirrors the
4167                // pragmatic UX: clicking "show similar" on an entity
4168                // still surfaces the entity's triples without surfacing a
4169                // pointless error.
4170                if matches!(kind, GraphNeighborsKind::Semantic) {
4171                    return Err(e);
4172                }
4173                (Vec::new(), Vec::new())
4174            }
4175        }
4176    } else {
4177        (Vec::new(), Vec::new())
4178    };
4179
4180    // Merge + dedupe. Explicit edges win over semantic edges with the
4181    // same (source, target). Nodes dedupe by id.
4182    let mut explicit_endpoints: std::collections::HashSet<(String, String)> =
4183        std::collections::HashSet::with_capacity(explicit_edges.len());
4184    for e in &explicit_edges {
4185        explicit_endpoints.insert((e.source.clone(), e.target.clone()));
4186    }
4187
4188    let mut nodes: Vec<GraphNode> = Vec::with_capacity(explicit_nodes.len() + semantic_nodes.len());
4189    let mut edges: Vec<GraphEdge> =
4190        Vec::with_capacity(explicit_edges.len() + semantic_edges.len());
4191    let mut seen_node_ids: std::collections::HashSet<String> =
4192        std::collections::HashSet::with_capacity(explicit_nodes.len() + semantic_nodes.len());
4193
4194    for n in explicit_nodes {
4195        if seen_node_ids.insert(n.id.clone()) {
4196            nodes.push(n);
4197        }
4198    }
4199    for e in explicit_edges {
4200        edges.push(e);
4201    }
4202    for n in semantic_nodes {
4203        if seen_node_ids.insert(n.id.clone()) {
4204            nodes.push(n);
4205        }
4206    }
4207    for e in semantic_edges {
4208        if explicit_endpoints.contains(&(e.source.clone(), e.target.clone())) {
4209            // Explicit edge already covers this pair -- drop the semantic
4210            // duplicate per the dedup rule. The semantic node may still
4211            // remain in `nodes` if no other edge already pulled it in;
4212            // that's fine -- the renderer renders nodes with weight-less
4213            // structural edges either way.
4214            continue;
4215        }
4216        edges.push(e);
4217    }
4218
4219    Ok(Json(GraphExpandResponse { nodes, edges }))
4220}
4221
4222/// Existence probe for the focal node. Translates the prefixed id into a
4223/// per-kind COUNT query against the matching table. Returns 404 (not 200
4224/// with empty arrays) when the node doesn't exist in the tenant's DB.
4225/// For entities the "existence" check is "is this entity referenced by
4226/// at least one triple" -- consistent with the inspect-entity contract
4227/// from `0115`.
4228async fn ensure_neighbors_focal_exists(
4229    tenant: &TenantHandle,
4230    node_kind: NodeKind,
4231    value: &str,
4232    node_id_full: &str,
4233) -> Result<(), ApiError> {
4234    match node_kind {
4235        NodeKind::Episode => ensure_episode_exists(tenant, value, node_id_full).await,
4236        NodeKind::Cluster => ensure_cluster_exists(tenant, value, node_id_full).await,
4237        NodeKind::Document => ensure_document_exists(tenant, value, node_id_full).await,
4238        NodeKind::Chunk => ensure_chunk_exists(tenant, value, node_id_full).await,
4239        NodeKind::Entity => ensure_entity_referenced(tenant, value, node_id_full).await,
4240    }
4241}
4242
4243/// 404 if the chunk_id has no row in this tenant's `document_chunks`
4244/// table whose parent doc is active. Mirrors `ensure_*_exists` from
4245/// `expand`.
4246async fn ensure_chunk_exists(
4247    tenant: &TenantHandle,
4248    chunk_id: &str,
4249    node_id_full: &str,
4250) -> Result<(), ApiError> {
4251    let chunk_id_q = chunk_id.to_string();
4252    let exists: i64 = tenant
4253        .read()
4254        .interact(move |conn| {
4255            conn.query_row(
4256                "SELECT COUNT(*)
4257                   FROM document_chunks c
4258                   JOIN documents d ON d.doc_id = c.doc_id
4259                  WHERE c.chunk_id = ?1
4260                    AND d.status = 'active'",
4261                rusqlite::params![&chunk_id_q],
4262                |r| r.get(0),
4263            )
4264        })
4265        .await
4266        .map_err(ApiError::from)?;
4267    if exists == 0 {
4268        return Err(ApiError::not_found(format!(
4269            "node_id {node_id_full:?} not found in current tenant"
4270        )));
4271    }
4272    Ok(())
4273}
4274
4275/// 404 if the entity isn't referenced by at least one active triple in
4276/// the tenant. Matches the inspect-entity 404 contract: entities are
4277/// synthetic, "existence" is "shows up in at least one triple".
4278async fn ensure_entity_referenced(
4279    tenant: &TenantHandle,
4280    entity_value: &str,
4281    node_id_full: &str,
4282) -> Result<(), ApiError> {
4283    let entity_q = entity_value.to_string();
4284    let exists: i64 = tenant
4285        .read()
4286        .interact(move |conn| {
4287            conn.query_row(
4288                "SELECT COUNT(*)
4289                   FROM triples
4290                  WHERE (subject_id = ?1 OR object_id = ?1)
4291                    AND status = 'active'",
4292                rusqlite::params![&entity_q],
4293                |r| r.get(0),
4294            )
4295        })
4296        .await
4297        .map_err(ApiError::from)?;
4298    if exists == 0 {
4299        return Err(ApiError::not_found(format!(
4300            "node_id {node_id_full:?} (entity {entity_value:?}) not found in current tenant -- entities must be referenced by at least one triple to be neighborable"
4301        )));
4302    }
4303    Ok(())
4304}
4305
4306/// Explicit-neighbor path. Dispatches per focal node kind, calling the
4307/// existing `expand_*` async fns for each compatible edge kind and
4308/// concatenating the results. This is the "reuse" refactor decision:
4309/// no duplication of expand's SQL, and expand's tests stay byte-for-byte
4310/// intact because we don't touch its bodies.
4311async fn neighbors_explicit(
4312    tenant: &TenantHandle,
4313    tenant_id: &str,
4314    node_kind: NodeKind,
4315    value: &str,
4316    node_id_full: &str,
4317    limit: i64,
4318) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4319    let mut nodes: Vec<GraphNode> = Vec::new();
4320    let mut edges: Vec<GraphEdge> = Vec::new();
4321
4322    match node_kind {
4323        NodeKind::Episode => {
4324            // Episodes have two compatible explicit-edge kinds:
4325            //   * cluster_member (episode -> clusters)
4326            //   * triple (episode -> entities, plus subj/obj entity pairs)
4327            //
4328            // document_chunk doesn't apply (episodes aren't documents).
4329            // Run each path, concat. Per-kind limit -- the caller asked for
4330            // up to `limit` neighbors PER KIND.
4331            let r1 = expand_cluster_member(tenant, tenant_id, node_kind, value, node_id_full, limit)
4332                .await?;
4333            nodes.extend(r1.nodes);
4334            edges.extend(r1.edges);
4335            let r2 =
4336                expand_triple(tenant, tenant_id, node_kind, value, node_id_full, limit).await?;
4337            nodes.extend(r2.nodes);
4338            edges.extend(r2.edges);
4339        }
4340        NodeKind::Document => {
4341            // Documents have one compatible explicit-edge kind:
4342            // document_chunk (document -> chunks).
4343            let r = expand_document_chunk(tenant, tenant_id, node_kind, value, node_id_full, limit)
4344                .await?;
4345            nodes.extend(r.nodes);
4346            edges.extend(r.edges);
4347        }
4348        NodeKind::Chunk => {
4349            // Chunks have one compatible explicit-edge kind:
4350            // document_chunk (chunk -> parent document).
4351            let r = expand_document_chunk(tenant, tenant_id, node_kind, value, node_id_full, limit)
4352                .await?;
4353            nodes.extend(r.nodes);
4354            edges.extend(r.edges);
4355        }
4356        NodeKind::Cluster => {
4357            // Clusters have one compatible explicit-edge kind:
4358            // cluster_member (cluster -> episodes).
4359            let r = expand_cluster_member(tenant, tenant_id, node_kind, value, node_id_full, limit)
4360                .await?;
4361            nodes.extend(r.nodes);
4362            edges.extend(r.edges);
4363        }
4364        NodeKind::Entity => {
4365            // Entities have one compatible explicit-edge kind:
4366            // triple (entity -> episodes where this entity is referenced).
4367            let r =
4368                expand_triple(tenant, tenant_id, node_kind, value, node_id_full, limit).await?;
4369            nodes.extend(r.nodes);
4370            edges.extend(r.edges);
4371        }
4372    }
4373    Ok((nodes, edges))
4374}
4375
4376/// Semantic-neighbor path. Only valid for episode + chunk focal nodes;
4377/// other kinds return 400. Reuses the existing inner pipelines:
4378///
4379///   * Episodes -> `solo_query::recall::run_recall_inner` (same path
4380///     `expand_semantic` uses; filters out chunk hits).
4381///   * Chunks   -> `solo_query::doc_search::run_doc_search_inner` (the
4382///     equivalent chunk-restricted vector pipeline).
4383///
4384/// Re-embed the focal node's content for the HNSW query rather than
4385/// loading the persisted vector from `embeddings` -- the same trade-off
4386/// `expand_semantic` made: cheaper code path overall, with deterministic
4387/// embedders in tests + batch-sized embedders in prod making the recompute
4388/// cost negligible.
4389async fn neighbors_semantic(
4390    tenant: &TenantHandle,
4391    tenant_id: &str,
4392    node_kind: NodeKind,
4393    value: &str,
4394    node_id_full: &str,
4395    limit: u32,
4396    threshold: f32,
4397) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4398    match node_kind {
4399        NodeKind::Episode => {
4400            neighbors_semantic_from_episode(
4401                tenant,
4402                tenant_id,
4403                value,
4404                node_id_full,
4405                limit,
4406                threshold,
4407            )
4408            .await
4409        }
4410        NodeKind::Chunk => {
4411            neighbors_semantic_from_chunk(
4412                tenant,
4413                tenant_id,
4414                value,
4415                node_id_full,
4416                limit,
4417                threshold,
4418            )
4419            .await
4420        }
4421        _ => Err(ApiError::bad_request(format!(
4422            "semantic neighbors only valid for episode or chunk source; got {}",
4423            node_kind.as_wire_str()
4424        ))),
4425    }
4426}
4427
4428async fn neighbors_semantic_from_episode(
4429    tenant: &TenantHandle,
4430    tenant_id: &str,
4431    memory_id: &str,
4432    node_id_full: &str,
4433    limit: u32,
4434    threshold: f32,
4435) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4436    let memory_id_q = memory_id.to_string();
4437    let memory_id_for_self_excl = memory_id.to_string();
4438    let content: Option<String> = tenant
4439        .read()
4440        .interact(move |conn| {
4441            conn.query_row(
4442                "SELECT content FROM episodes WHERE memory_id = ?1 AND status = 'active'",
4443                rusqlite::params![&memory_id_q],
4444                |r| r.get::<_, String>(0),
4445            )
4446            .map(Some)
4447            .or_else(|e| match e {
4448                rusqlite::Error::QueryReturnedNoRows => Ok(None),
4449                other => Err(other),
4450            })
4451        })
4452        .await
4453        .map_err(ApiError::from)?;
4454
4455    // Existence is guaranteed by the focal-exists probe earlier; an
4456    // empty content here would be a status-transition race we treat as
4457    // "nothing to compare against".
4458    let Some(content) = content else {
4459        return Ok((Vec::new(), Vec::new()));
4460    };
4461
4462    // Widen the request by 1 so dropping self doesn't shrink the page.
4463    let widened = (limit as usize).saturating_add(1).min(100);
4464    let result = solo_query::recall::run_recall_inner(
4465        tenant.embedder(),
4466        tenant.hnsw(),
4467        tenant.read(),
4468        &content,
4469        widened,
4470    )
4471    .await
4472    .map_err(ApiError::from)?;
4473
4474    let mut nodes = Vec::new();
4475    let mut edges = Vec::new();
4476    for hit in result.hits.into_iter() {
4477        if hit.memory_id == memory_id_for_self_excl {
4478            // Skip self.
4479            continue;
4480        }
4481        if nodes.len() as u32 >= limit {
4482            break;
4483        }
4484        let weight = (1.0 - hit.cos_distance).max(0.0);
4485        if weight < threshold {
4486            continue;
4487        }
4488        let target_id = format!("ep:{}", hit.memory_id);
4489        edges.push(GraphEdge {
4490            id: edge_id(node_id_full, "semantic", &target_id),
4491            source: node_id_full.to_string(),
4492            target: target_id,
4493            kind: "semantic",
4494            predicate: None,
4495            weight: Some(weight),
4496        });
4497        nodes.push(GraphNode {
4498            id: format!("ep:{}", hit.memory_id),
4499            kind: NodeKind::Episode.as_wire_str(),
4500            label: episode_label(&hit.content),
4501            ts_ms: None,
4502            tenant_id: tenant_id.to_string(),
4503            preview: Some(truncate_preview(&hit.content, GRAPH_PREVIEW_CHARS)),
4504        });
4505    }
4506    Ok((nodes, edges))
4507}
4508
4509async fn neighbors_semantic_from_chunk(
4510    tenant: &TenantHandle,
4511    tenant_id: &str,
4512    chunk_id: &str,
4513    node_id_full: &str,
4514    limit: u32,
4515    threshold: f32,
4516) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4517    let chunk_id_q = chunk_id.to_string();
4518    let chunk_id_for_self_excl = chunk_id.to_string();
4519    let content: Option<String> = tenant
4520        .read()
4521        .interact(move |conn| {
4522            conn.query_row(
4523                "SELECT c.content
4524                   FROM document_chunks c
4525                   JOIN documents d ON d.doc_id = c.doc_id
4526                  WHERE c.chunk_id = ?1
4527                    AND d.status = 'active'",
4528                rusqlite::params![&chunk_id_q],
4529                |r| r.get::<_, String>(0),
4530            )
4531            .map(Some)
4532            .or_else(|e| match e {
4533                rusqlite::Error::QueryReturnedNoRows => Ok(None),
4534                other => Err(other),
4535            })
4536        })
4537        .await
4538        .map_err(ApiError::from)?;
4539
4540    let Some(content) = content else {
4541        return Ok((Vec::new(), Vec::new()));
4542    };
4543
4544    let widened = (limit as usize).saturating_add(1).min(100);
4545    let hits = solo_query::doc_search::run_doc_search_inner(
4546        tenant.embedder(),
4547        tenant.hnsw(),
4548        tenant.read(),
4549        &content,
4550        widened,
4551    )
4552    .await
4553    .map_err(ApiError::from)?;
4554
4555    let mut nodes = Vec::new();
4556    let mut edges = Vec::new();
4557    for hit in hits.into_iter() {
4558        if hit.chunk_id == chunk_id_for_self_excl {
4559            continue;
4560        }
4561        if nodes.len() as u32 >= limit {
4562            break;
4563        }
4564        let weight = (1.0 - hit.cos_distance).max(0.0);
4565        if weight < threshold {
4566            continue;
4567        }
4568        let target_id = format!("chunk:{}", hit.chunk_id);
4569        edges.push(GraphEdge {
4570            id: edge_id(node_id_full, "semantic", &target_id),
4571            source: node_id_full.to_string(),
4572            target: target_id,
4573            kind: "semantic",
4574            predicate: None,
4575            weight: Some(weight),
4576        });
4577        let exp = ExpandedChunk {
4578            chunk_id: hit.chunk_id.clone(),
4579            chunk_index: hit.chunk_index as i64,
4580            content: hit.content.clone(),
4581        };
4582        nodes.push(graph_node_for_chunk(tenant_id, &exp));
4583    }
4584    Ok((nodes, edges))
4585}
4586
4587// ---------------------------------------------------------------------------
4588// /v1/graph/stream — SSE invalidation feed (v0.10.0)
4589//
4590// Powers solo-web's live-update behaviour: instead of polling, the
4591// frontend subscribes once and refetches its pages only when the
4592// writer-actor signals "your tenant's data changed". Per scoping doc
4593// §3 Decision C, the wire format is invalidation-shaped (not row
4594// payload) — the SSE channel says "refetch the affected page" rather
4595// than streaming actual rows.
4596//
4597// Wire format:
4598//
4599//   ```
4600//   event: init
4601//   data: {"connected": true, "tenant_id": "default", "ts_ms": 1715625600000}
4602//
4603//   event: invalidate
4604//   data: {"reason": "memory.remember", "tenant_id": "default",
4605//          "ts_ms": 1715625610000, "kind": "episode"}
4606//
4607//   event: heartbeat
4608//   data: {"ts_ms": 1715625640000}
4609//   ```
4610//
4611// Heartbeat: every [`STREAM_HEARTBEAT_SECS`] seconds, regardless of
4612// whether real events fired (simpler than resetting the timer on every
4613// invalidate; the cost is a few extra bytes per minute on idle).
4614//
4615// Lagged subscribers (subscriber polled slower than 256 writes) see one
4616// emit-only-once warning and resync via the next real `invalidate` —
4617// invalidation events are idempotent, so the missed batch reduces to a
4618// single refetch on the client side. No correctness loss.
4619//
4620// See `docs/dev-log/0117-graph-stream-impl.md` for the full design.
4621// ---------------------------------------------------------------------------
4622
4623/// Heartbeat interval for `/v1/graph/stream`. Fires unconditionally
4624/// every 30 seconds — easier to reason about than "fire 30s after the
4625/// last event", and keeps proxies happy without code that races a
4626/// reset on every invalidate.
4627pub const STREAM_HEARTBEAT_SECS: u64 = 30;
4628
4629/// SSE event name emitted on connection open. Single fire; client uses
4630/// this to confirm the subscription is live.
4631const STREAM_EVENT_INIT: &str = "init";
4632
4633/// SSE event name emitted on every writer-actor commit (and on
4634/// `gdpr.forget_user`'s non-writer-actor cascade).
4635const STREAM_EVENT_INVALIDATE: &str = "invalidate";
4636
4637/// SSE event name emitted by the heartbeat interval.
4638const STREAM_EVENT_HEARTBEAT: &str = "heartbeat";
4639
4640/// `GET /v1/graph/stream` — Server-Sent Events feed of
4641/// `InvalidateEvent`s scoped to the request's tenant.
4642///
4643/// Subscribes to the per-tenant `broadcast::Sender<InvalidateEvent>`
4644/// held by `TenantHandle` (populated by `TenantHandle::open`). The
4645/// stream:
4646///
4647///   1. Emits one `event: init` line at connection open.
4648///   2. Selects between (broadcast recv) and (heartbeat tick) in a
4649///      loop, emitting `invalidate` / `heartbeat` events as either
4650///      fires.
4651///   3. Exits when the client closes the connection (axum drops the
4652///      response future) OR the broadcast Sender is dropped (tenant
4653///      shutdown).
4654///
4655/// Auth + tenant resolution mirror the rest of `/v1/graph/*`: the
4656/// `auth_middleware` returns 401 on missing bearer; the
4657/// `TenantExtractor` resolves the per-tenant DB. The handler itself
4658/// has no per-route auth logic.
4659async fn graph_stream_handler(
4660    TenantExtractor(tenant): TenantExtractor,
4661) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
4662    // Subscribe BEFORE building the init event so a writer-actor
4663    // commit that lands in the (microscopic) window between init and
4664    // the first poll is still observed. `broadcast::Receiver` buffers
4665    // up to the channel's capacity from the moment of subscribe.
4666    let rx = tenant.invalidate_sender().subscribe();
4667    let tenant_id = tenant.tenant_id().to_string();
4668    let stream = build_invalidate_stream(rx, tenant_id, STREAM_HEARTBEAT_SECS);
4669    // axum's keep-alive layer adds its own `:` comment line every
4670    // configured interval; we keep that OFF and ship our own typed
4671    // `heartbeat` event instead. The client distinguishes the two by
4672    // looking at the SSE `event:` field — typed heartbeats let solo-web
4673    // surface "connection healthy" in its UI without parsing comment
4674    // lines.
4675    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(3600)))
4676}
4677
4678/// Per-subscriber state threaded through `futures::stream::unfold`.
4679/// Carries the receiver + heartbeat interval + a one-shot flag for
4680/// the initial `init` event.
4681struct StreamState {
4682    rx: broadcast::Receiver<InvalidateEvent>,
4683    heartbeat: tokio::time::Interval,
4684    tenant_id: String,
4685    /// `true` until the first poll completes — used to gate the `init`
4686    /// event. Flipped to `false` after the init event yields.
4687    needs_init: bool,
4688}
4689
4690/// Build the stream of SSE [`Event`]s for one subscriber.
4691///
4692/// First yield is the `init` event. After that, the stream selects
4693/// between the broadcast receiver and a tokio interval timer that
4694/// fires every `heartbeat_secs` seconds. Lagged broadcast errors are
4695/// swallowed with a single `tracing::warn!` line — the client resyncs
4696/// on the next real invalidate (invalidation events are idempotent).
4697fn build_invalidate_stream(
4698    rx: broadcast::Receiver<InvalidateEvent>,
4699    tenant_id: String,
4700    heartbeat_secs: u64,
4701) -> impl Stream<Item = Result<Event, Infallible>> {
4702    // `tokio::time::interval_at(start, period)` starts ticking at
4703    // `start`; we set `start = now + period` so the first heartbeat
4704    // lands `heartbeat_secs` AFTER the init event. Without `interval_at`
4705    // the default `interval()` would fire immediately at t=0, racing
4706    // the init event.
4707    let start_at = tokio::time::Instant::now() + Duration::from_secs(heartbeat_secs);
4708    let heartbeat =
4709        tokio::time::interval_at(start_at, Duration::from_secs(heartbeat_secs));
4710
4711    let state = StreamState {
4712        rx,
4713        heartbeat,
4714        tenant_id,
4715        needs_init: true,
4716    };
4717    futures::stream::unfold(state, move |mut state| async move {
4718        // First-poll: yield the init event without touching the
4719        // receiver or the heartbeat. Subsequent polls fall through to
4720        // the select loop.
4721        if state.needs_init {
4722            state.needs_init = false;
4723            let init_payload = serde_json::json!({
4724                "connected": true,
4725                "tenant_id": state.tenant_id,
4726                "ts_ms": chrono::Utc::now().timestamp_millis(),
4727            });
4728            let ev = Event::default()
4729                .event(STREAM_EVENT_INIT)
4730                .json_data(init_payload)
4731                .unwrap_or_else(|_| Event::default().event(STREAM_EVENT_INIT));
4732            return Some((Ok::<Event, Infallible>(ev), state));
4733        }
4734        loop {
4735            tokio::select! {
4736                event = state.rx.recv() => {
4737                    match event {
4738                        Ok(ev) => {
4739                            let sse_event = Event::default()
4740                                .event(STREAM_EVENT_INVALIDATE)
4741                                .json_data(&ev)
4742                                .unwrap_or_else(|_| Event::default()
4743                                    .event(STREAM_EVENT_INVALIDATE));
4744                            return Some((Ok::<Event, Infallible>(sse_event), state));
4745                        }
4746                        Err(broadcast::error::RecvError::Lagged(n)) => {
4747                            tracing::warn!(
4748                                lagged = n,
4749                                "graph stream subscriber lagged; client will \
4750                                 resync on the next real invalidate"
4751                            );
4752                            // Continue receiving — do NOT yield anything
4753                            // for a lag.
4754                        }
4755                        Err(broadcast::error::RecvError::Closed) => {
4756                            tracing::debug!(
4757                                "graph stream broadcast closed; ending SSE stream"
4758                            );
4759                            return None;
4760                        }
4761                    }
4762                }
4763                _ = state.heartbeat.tick() => {
4764                    let hb_payload = serde_json::json!({
4765                        "ts_ms": chrono::Utc::now().timestamp_millis(),
4766                    });
4767                    let sse_event = Event::default()
4768                        .event(STREAM_EVENT_HEARTBEAT)
4769                        .json_data(hb_payload)
4770                        .unwrap_or_else(|_| Event::default()
4771                            .event(STREAM_EVENT_HEARTBEAT));
4772                    return Some((Ok::<Event, Infallible>(sse_event), state));
4773                }
4774            }
4775        }
4776    })
4777}
4778
4779// ---------------------------------------------------------------------------
4780// /v1/tenants — principal-scoped tenant list (v0.10.0 + v0.10.1 hydration)
4781//
4782// Powers solo-web's top-bar tenant picker (Decision F in
4783// `docs/dev-log/0105-solo-web-scoping.md` §3, route shape locked in §4
4784// Route 6). The endpoint is **read-only**; admin CRUD (create / delete /
4785// rename / quota change) remains CLI-only per ADR-0004 §"Admin operations".
4786// That keeps the privileged tenant-mutation surface off HTTP entirely
4787// while still letting an authenticated browser session enumerate the
4788// tenants it's allowed to see.
4789//
4790// Wire shape (200 OK):
4791//
4792//   ```json
4793//   {
4794//     "tenants": [
4795//       {
4796//         "id": "default",
4797//         "display_name": "Default tenant",
4798//         "created_at_ms": 1715625600000,
4799//         "last_accessed_ms": 1715625900000,
4800//         "status": "active",
4801//         "quota_bytes": null,
4802//         "episode_count": null,
4803//         "size_bytes": null,
4804//         "pct_used": null
4805//       }
4806//     ]
4807//   }
4808//   ```
4809//
4810// The numeric `episode_count` / `size_bytes` / `pct_used` fields were
4811// **always `null` in v0.10.0** (cost-deferred). v0.10.1 hydrates them
4812// for real via `TenantRegistry::hydrate_tenant_cost_numbers`:
4813//
4814//   * `size_bytes` — `std::fs::metadata(<data_dir>/tenants/<db>.db).len()`.
4815//     Cheap; runs for every visible tenant.
4816//   * `episode_count` — `SELECT COUNT(*) FROM episodes WHERE
4817//     status='active'` against the per-tenant SQLCipher DB.
4818//   * `pct_used` — `size_bytes * 100 / quota_bytes` (f64, capped at
4819//     100.0) when both are known; `null` if `quota_bytes` is unset.
4820//
4821// **Cap**: opening + counting N tenant DBs is N×~10ms; the first-paint
4822// budget is tight, so we cap `episode_count` hydration at
4823// `TENANTS_COUNT_HYDRATION_CAP` (50) per request. Tenants beyond the
4824// cap get `episode_count: null` and the response carries an
4825// `X-Solo-Tenants-Count-Cap-Reached: true` header so clients can fetch
4826// counts for the tail tenants out-of-band if needed (mirroring the
4827// entity-cap pattern from `/v1/graph/nodes`). `size_bytes` is not
4828// capped — it's just a `metadata` call.
4829//
4830// The CLI's `solo tenants list` retains the canonical per-tenant
4831// cost-numbers path for operators who need exhaustive data.
4832//
4833// ## Visibility filter (load-bearing — three cases)
4834//
4835// The handler reads `AuthenticatedPrincipal` out of request extensions
4836// via `MaybePrincipal` and filters the registry list before
4837// serialisation:
4838//
4839//   1. **No principal** (`MaybePrincipal(None)`) — unauthenticated
4840//      loopback path, no `[auth]` block in `solo.config.toml`. Return
4841//      every `Active` tenant. Same scope as `solo tenants list` CLI.
4842//   2. **Bearer principal** (`subject == "bearer" && claims.is_null()`,
4843//      the `AuthenticatedPrincipal::bearer` signature emitted by
4844//      `BearerValidator::validate`). Single-principal daemon — the
4845//      bearer holder is the operator, so return every `Active`
4846//      tenant. Functionally equivalent to (1) from a leakage
4847//      standpoint.
4848//   3. **OIDC principal** (any other principal — `claims` carries the
4849//      JWT object). Filter to ONLY the tenant id matching
4850//      `principal.tenant_claim`. The configured OIDC tenant_claim is
4851//      already validated to a real `TenantId` by the auth middleware
4852//      (a `MissingTenantClaim` or `InvalidTenantClaim` shorts out at
4853//      403 BEFORE this handler runs). If the claim doesn't match any
4854//      registered tenant, return `{"tenants": []}` (200 OK, NOT 404)
4855//      — don't leak whether a tenant exists by 404'ing on names
4856//      outside the principal's scope.
4857//
4858// `PendingMigration` / `PendingDelete` tenants are **excluded** from the
4859// list in every case. solo-web's tenant picker should not surface a
4860// tenant that's mid-migration or queued for hard-delete — clicking
4861// such a row would race the admin tooling. The CLI's `solo tenants
4862// list` still shows them under an explicit `--include-pending` flag
4863// (out of scope here).
4864//
4865// See `docs/dev-log/0119-tenants-list-impl.md` for the full design.
4866// ---------------------------------------------------------------------------
4867
4868/// One row of the `/v1/tenants` response body. Shape mirrors
4869/// `solo_storage::TenantRecord` for the persisted fields plus the
4870/// reserved-for-future cost-numbers triple (`episode_count`,
4871/// `size_bytes`, `pct_used`) that v0.10.0 always sets to `null`.
4872#[derive(Debug, Clone, Serialize)]
4873struct TenantListItem {
4874    /// Tenant id (e.g. `"default"`, `"alice"`). Matches the
4875    /// `X-Solo-Tenant` header value clients send to other routes.
4876    id: String,
4877    /// Human-readable display name set at `solo tenants create`.
4878    /// `None` ⇒ omit from the JSON body.
4879    #[serde(skip_serializing_if = "Option::is_none")]
4880    display_name: Option<String>,
4881    /// Epoch ms when this tenant was registered.
4882    created_at_ms: i64,
4883    /// Epoch ms of the most recent `TenantRegistry::get_or_open` call
4884    /// (v0.9.0 P1). `None` for tenants that have never been opened
4885    /// since the migration ran.
4886    #[serde(skip_serializing_if = "Option::is_none")]
4887    last_accessed_ms: Option<i64>,
4888    /// Lifecycle status. Always `"active"` in the v0.10.0 wire (we
4889    /// filter `PendingMigration` / `PendingDelete` out at list time).
4890    /// Surfaced for forward-compat — a future `?include_pending=1`
4891    /// query param could relax the filter without a shape change.
4892    status: TenantStatusJson,
4893    /// Per-tenant byte quota set via `solo tenants set-quota`. `None`
4894    /// ⇒ unlimited.
4895    #[serde(skip_serializing_if = "Option::is_none")]
4896    quota_bytes: Option<u64>,
4897    /// v0.10.1: count of `episodes WHERE status='active'`. Populated
4898    /// for the first `TENANTS_COUNT_HYDRATION_CAP` tenants in the
4899    /// response; `null` for tenants beyond the cap (in which case the
4900    /// response also carries `X-Solo-Tenants-Count-Cap-Reached: true`).
4901    /// Also `null` if the per-tenant DB file is missing or the COUNT
4902    /// failed.
4903    episode_count: Option<i64>,
4904    /// v0.10.1: size of the per-tenant SQLCipher DB on disk (bytes).
4905    /// `null` only if the file is missing or unreadable (corruption /
4906    /// permissions). Not affected by the cap — `std::fs::metadata` is
4907    /// cheap.
4908    size_bytes: Option<u64>,
4909    /// v0.10.1: `(size_bytes * 100.0 / quota_bytes)` capped at `100.0`
4910    /// when both `size_bytes` and `quota_bytes` are known. `null` if
4911    /// `quota_bytes` is unset (no quota = unlimited) or `size_bytes`
4912    /// is unknown.
4913    pct_used: Option<f64>,
4914}
4915
4916/// JSON-side mirror of [`TenantStatus`]. Re-defined here (rather than
4917/// using `#[derive(Serialize)]` on `TenantStatus` directly — which it
4918/// already has via `#[serde(rename_all = "snake_case")]`) so the
4919/// HTTP-side wire shape stays decoupled from the storage-side enum.
4920/// Today both serialise identically; a future status variant added to
4921/// storage doesn't automatically leak onto the wire.
4922#[derive(Debug, Clone, Copy, Serialize)]
4923#[serde(rename_all = "snake_case")]
4924enum TenantStatusJson {
4925    Active,
4926}
4927
4928impl From<&solo_storage::TenantStatus> for TenantStatusJson {
4929    fn from(s: &solo_storage::TenantStatus) -> Self {
4930        // We only ever build this enum from `Active` records (the list
4931        // handler filters at source); the match exhausts so future
4932        // variants force a compile error here, not a wire mismatch.
4933        match s {
4934            solo_storage::TenantStatus::Active => TenantStatusJson::Active,
4935            // Defensive: should be filtered upstream. Map to Active to
4936            // avoid a panic, but the handler MUST keep filtering at
4937            // source. A clippy warning catches dead branches.
4938            solo_storage::TenantStatus::PendingMigration
4939            | solo_storage::TenantStatus::PendingDelete => TenantStatusJson::Active,
4940        }
4941    }
4942}
4943
4944/// Response body for `GET /v1/tenants`.
4945#[derive(Debug, Serialize)]
4946struct TenantsListResponse {
4947    tenants: Vec<TenantListItem>,
4948}
4949
4950/// v0.10.1: maximum number of tenants whose `episode_count` we hydrate
4951/// per `/v1/tenants` request. Opening + counting one tenant DB is
4952/// ~5-10ms; capping bounds the per-request wall to keep solo-web's
4953/// first-paint budget tight. Tenants beyond the cap get
4954/// `episode_count: null` AND the response carries
4955/// `X-Solo-Tenants-Count-Cap-Reached: true` so clients can fetch
4956/// per-tenant counts out-of-band (CLI / future per-id endpoint) for
4957/// the tail. The 50 figure mirrors the entity-cap pattern from
4958/// `/v1/graph/nodes`.
4959const TENANTS_COUNT_HYDRATION_CAP: usize = 50;
4960
4961/// v0.10.1: response header name set to `"true"` when the per-request
4962/// `episode_count` hydration cap was reached. Absent otherwise.
4963/// Grep-able by both server- and client-side code. Stored lowercase
4964/// per `axum::http::HeaderName::from_static` (header names are
4965/// case-insensitive on the wire; the canonical spelling is
4966/// `X-Solo-Tenants-Count-Cap-Reached`).
4967const X_SOLO_TENANTS_COUNT_CAP_HEADER: &str = "x-solo-tenants-count-cap-reached";
4968
4969/// `GET /v1/tenants` — list every tenant visible to the request's
4970/// principal. See module comment for the three-case visibility rule.
4971///
4972/// Errors:
4973///   * **401** — bearer required but missing/invalid (handled by
4974///     `auth_middleware` before this handler runs).
4975///   * **500** — `TenantsIndex` read failed. Surfaced via [`ApiError`].
4976///
4977/// No 404 path. If the OIDC principal's `tenant_claim` doesn't match
4978/// any registered tenant, the response is `200 OK` with `tenants:
4979/// []`. That keeps tenant existence out of side-channel range for an
4980/// OIDC user — they cannot probe for other tenants by id.
4981async fn tenants_list_handler(
4982    State(state): State<SoloHttpState>,
4983    MaybePrincipal(maybe_principal): MaybePrincipal,
4984) -> Result<Response, ApiError> {
4985    // Pull every registered tenant. `list_active` is the registry's
4986    // wrapper around `TenantsIndex::list`, which returns rows ordered
4987    // by `(created_at_ms ASC, tenant_id ASC)` — a stable order that
4988    // doesn't shift between requests, which solo-web relies on to keep
4989    // its tenant picker entries from reordering visually.
4990    let mut records = state.registry.list_active().await.map_err(ApiError::from)?;
4991
4992    // Filter at source: status MUST be Active (PendingMigration /
4993    // PendingDelete are admin-transient states that solo-web should
4994    // not surface). Matches the brief's
4995    // `tenants_status_filter_excludes_deleted` test.
4996    records.retain(|r| matches!(r.status, solo_storage::TenantStatus::Active));
4997
4998    // Apply the principal-driven visibility filter. The three cases
4999    // are exhaustive — see the module comment for the rationale on
5000    // each. `tenant_visibility_filter` is split out so the unit
5001    // tests can assert the rule independent of the SQL read.
5002    let filtered = filter_tenants_for_principal(records, maybe_principal.as_ref());
5003
5004    // v0.10.1: hydrate cost numbers (size_bytes, episode_count). The
5005    // registry helper handles missing DB files + the cap behavior. We
5006    // pass the cap so tenants beyond it return `None` for episode_count
5007    // — `size_bytes` is computed for everyone (cheap fs::metadata).
5008    let cap = TENANTS_COUNT_HYDRATION_CAP;
5009    let costs = state
5010        .registry
5011        .hydrate_tenant_cost_numbers(&filtered, cap)
5012        .await;
5013    let cap_reached = filtered.len() > cap;
5014
5015    let tenants: Vec<TenantListItem> = filtered
5016        .iter()
5017        .zip(costs.iter())
5018        .map(|(r, cost)| {
5019            let pct_used = match (cost.size_bytes, r.quota_bytes) {
5020                (Some(size), Some(quota)) if quota > 0 => {
5021                    let raw = (size as f64) * 100.0 / (quota as f64);
5022                    Some(raw.min(100.0))
5023                }
5024                _ => None,
5025            };
5026            TenantListItem {
5027                id: r.tenant_id.to_string(),
5028                display_name: r.display_name.clone(),
5029                created_at_ms: r.created_at_ms,
5030                last_accessed_ms: r.last_accessed_ms,
5031                status: TenantStatusJson::from(&r.status),
5032                quota_bytes: r.quota_bytes,
5033                episode_count: cost.episode_count,
5034                size_bytes: cost.size_bytes,
5035                pct_used,
5036            }
5037        })
5038        .collect();
5039
5040    let body = Json(TenantsListResponse { tenants });
5041    if cap_reached {
5042        let mut resp = body.into_response();
5043        resp.headers_mut().insert(
5044            axum::http::HeaderName::from_static(X_SOLO_TENANTS_COUNT_CAP_HEADER),
5045            axum::http::HeaderValue::from_static("true"),
5046        );
5047        Ok(resp)
5048    } else {
5049        Ok(body.into_response())
5050    }
5051}
5052
5053/// Pure function: apply the three-case principal-driven visibility
5054/// rule to a list of `TenantRecord`s. Extracted from the handler so
5055/// unit tests can exercise the rule without driving an axum router.
5056///
5057///   * `principal == None` ⇒ all records returned (no-auth path).
5058///   * Bearer-shaped principal (`subject == "bearer" && claims.is_null()`)
5059///     ⇒ all records returned (single-principal daemon).
5060///   * Any other principal (OIDC) ⇒ filter to records whose
5061///     `tenant_id == principal.tenant_claim`. An OIDC principal with
5062///     no `tenant_claim` (theoretically unreachable — the middleware
5063///     short-circuits at 403 before us, but we defend) returns an
5064///     empty list.
5065fn filter_tenants_for_principal(
5066    records: Vec<solo_storage::TenantRecord>,
5067    principal: Option<&AuthenticatedPrincipal>,
5068) -> Vec<solo_storage::TenantRecord> {
5069    let Some(p) = principal else {
5070        // Case 1: no auth configured — return all tenants. Same scope
5071        // as `solo tenants list`.
5072        return records;
5073    };
5074    if is_single_principal_bearer(p) {
5075        // Case 2: bearer principal — return all tenants. The single
5076        // bearer holder is functionally the daemon operator.
5077        return records;
5078    }
5079    // Case 3: OIDC principal — filter to the claimed tenant only. An
5080    // unmatched claim falls through to an empty list, NOT 404, to
5081    // avoid leaking tenant existence.
5082    let Some(claim) = p.tenant_claim.as_ref() else {
5083        return Vec::new();
5084    };
5085    records
5086        .into_iter()
5087        .filter(|r| r.tenant_id == *claim)
5088        .collect()
5089}
5090
5091/// True iff `principal` looks like a bearer-mode principal — the shape
5092/// emitted by [`AuthenticatedPrincipal::bearer`]: subject is literally
5093/// `"bearer"`, claims is `serde_json::Value::Null`, and scopes is
5094/// empty. OIDC principals carry a JWT object in `claims` and the JWT
5095/// `sub` in `subject`, so they fail this predicate.
5096///
5097/// Split out so the unit tests can assert the discriminator
5098/// independent of the rest of the handler. Keeping the predicate in
5099/// one place also makes future expansion easier — e.g., a v0.11
5100/// "admin scope" might add an OIDC variant that passes this gate by
5101/// looking for a `"solo:admin"` entry in `scopes`.
5102fn is_single_principal_bearer(principal: &AuthenticatedPrincipal) -> bool {
5103    principal.subject == "bearer"
5104        && principal.claims.is_null()
5105        && principal.scopes.is_empty()
5106}
5107
5108// ---------------------------------------------------------------------------
5109// Error mapping
5110// ---------------------------------------------------------------------------
5111
5112#[derive(Debug)]
5113pub struct ApiError {
5114    status: StatusCode,
5115    message: String,
5116}
5117
5118impl ApiError {
5119    fn bad_request(msg: impl Into<String>) -> Self {
5120        Self {
5121            status: StatusCode::BAD_REQUEST,
5122            message: msg.into(),
5123        }
5124    }
5125    fn not_found(msg: impl Into<String>) -> Self {
5126        Self {
5127            status: StatusCode::NOT_FOUND,
5128            message: msg.into(),
5129        }
5130    }
5131    fn internal(msg: impl Into<String>) -> Self {
5132        Self {
5133            status: StatusCode::INTERNAL_SERVER_ERROR,
5134            message: msg.into(),
5135        }
5136    }
5137}
5138
5139impl From<solo_core::Error> for ApiError {
5140    fn from(e: solo_core::Error) -> Self {
5141        use solo_core::Error;
5142        match e {
5143            Error::NotFound(msg) => ApiError::not_found(msg),
5144            Error::InvalidInput(msg) => ApiError::bad_request(msg),
5145            Error::Conflict(msg) => Self {
5146                status: StatusCode::CONFLICT,
5147                message: msg,
5148            },
5149            other => ApiError::internal(other.to_string()),
5150        }
5151    }
5152}
5153
5154impl IntoResponse for ApiError {
5155    fn into_response(self) -> Response {
5156        let body = serde_json::json!({
5157            "error": self.message,
5158            "status": self.status.as_u16(),
5159        });
5160        (self.status, Json(body)).into_response()
5161    }
5162}
5163
5164// SQL helper for recall used to live here; consolidated into
5165// solo_query::recall.
5166
5167#[cfg(test)]
5168mod handler_tests {
5169    //! In-process integration tests for the HTTP handler surface. We
5170    //! drive the axum Router directly via `tower::ServiceExt::oneshot`
5171    //! — no real TCP listener needed. Same `Harness`-shape as the MCP
5172    //! tests: real WriterActor + ReaderPool + StubEmbedder + StubVectorIndex.
5173    //!
5174    //! Tests live inline in this module rather than in a `tests/` dir
5175    //! because external integration-test exes triggered Windows UAC
5176    //! ERROR_ELEVATION_REQUIRED on the dev machine.
5177    use super::*;
5178    use axum::body::Body;
5179    use axum::http::{Request, StatusCode};
5180    use http_body_util::BodyExt;
5181    use serde_json::{Value, json};
5182    use solo_storage::test_support::StubVectorIndex;
5183    use solo_storage::{
5184        EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
5185        StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
5186    };
5187    use solo_core::VectorIndex;
5188    use std::sync::Arc as StdArc;
5189    use tower::ServiceExt;
5190
5191    fn fake_config(dim: u32) -> SoloConfig {
5192        SoloConfig {
5193            schema_version: 1,
5194            salt_hex: "00000000000000000000000000000000".to_string(),
5195            embedder: EmbedderConfig {
5196                name: "stub".to_string(),
5197                version: "v1".to_string(),
5198                dim,
5199                dtype: "f32".to_string(),
5200            },
5201            identity: IdentityConfig::default(),
5202            documents: solo_storage::DocumentConfig::default(),
5203            auth: None,
5204            audit: solo_storage::AuditSettings::default(),
5205            redaction: solo_storage::RedactionConfig::default(),
5206            llm: None,
5207            triples: solo_storage::TriplesConfig::default(),
5208            sampling: solo_storage::SamplingConfig::default(),
5209        }
5210    }
5211
5212    struct Harness {
5213        router: axum::Router,
5214        _tmp: tempfile::TempDir,
5215        db_path: std::path::PathBuf,
5216        write_handle_extra: Option<solo_storage::WriteHandle>,
5217        join: Option<std::thread::JoinHandle<()>>,
5218        /// v0.10.0: handle to the per-tenant TenantHandle so SSE-flavoured
5219        /// tests can call `harness.invalidate_sender().send(...)` to
5220        /// simulate writer-actor invalidations (or grab a Receiver via
5221        /// `.subscribe()` for subscriber-count assertions).
5222        tenant_handle: StdArc<TenantHandle>,
5223        /// v0.10.0: clone of the registry Arc so `/v1/tenants` tests can
5224        /// seed additional tenant rows into the in-memory tenants_index
5225        /// stub via `registry.with_index(|idx| idx.register(...))`.
5226        registry: StdArc<TenantRegistry>,
5227    }
5228
5229    impl Harness {
5230        /// v0.10.0: clone the per-tenant broadcast Sender so tests can
5231        /// fire `InvalidateEvent`s directly without going through the
5232        /// writer-actor. The harness's writer is spawned via
5233        /// `WriterActor::spawn_full` (legacy variant, no invalidate
5234        /// plumb) so writer-driven events won't reach SSE subscribers
5235        /// in tests — tests use this Sender to simulate them.
5236        fn invalidate_sender(&self) -> tokio::sync::broadcast::Sender<InvalidateEvent> {
5237            self.tenant_handle.invalidate_sender().clone()
5238        }
5239    }
5240
5241    impl Harness {
5242        fn new(runtime: &tokio::runtime::Runtime) -> Self {
5243            Self::new_with_auth(runtime, None)
5244        }
5245
5246        /// Open a fresh side connection against the harness's DB. Used
5247        /// by graph_expand tests to seed clusters / triples / documents
5248        /// directly (the writer-actor doesn't expose those write paths).
5249        fn open_db(&self) -> rusqlite::Connection {
5250            solo_storage::test_support::open_test_db_at(&self.db_path)
5251        }
5252
5253        fn new_with_auth(
5254            runtime: &tokio::runtime::Runtime,
5255            bearer_token: Option<String>,
5256        ) -> Self {
5257            Self::new_with_auth_config(
5258                runtime,
5259                bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
5260            )
5261        }
5262
5263        fn new_with_auth_config(
5264            runtime: &tokio::runtime::Runtime,
5265            auth: Option<crate::auth::AuthConfig>,
5266        ) -> Self {
5267            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
5268
5269            let tmp = tempfile::TempDir::new().unwrap();
5270            let dim = 16usize;
5271            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
5272            let embedder: StdArc<dyn solo_core::Embedder> =
5273                StdArc::new(StubEmbedder::new("stub", "v1", dim));
5274            let path = tmp.path().join("test.db");
5275
5276            let embedder_id = {
5277                let conn = solo_storage::test_support::open_test_db_at(&path);
5278                get_or_insert_embedder_id(
5279                    &conn,
5280                    &EmbedderIdentity {
5281                        name: "stub".into(),
5282                        version: "v1".into(),
5283                        dim: dim as u32,
5284                        dtype: "f32".into(),
5285                    },
5286                )
5287                .unwrap()
5288            };
5289
5290            let conn = solo_storage::test_support::open_test_db_at(&path);
5291            let WriterSpawn { handle, join } = WriterActor::spawn_full(
5292                conn,
5293                hnsw.clone(),
5294                tmp.path().to_path_buf(),
5295                embedder_id,
5296            );
5297            let pool: ReaderPool =
5298                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
5299
5300            // Build a TenantHandle from the assembled parts and wrap it
5301            // in a single-tenant test registry.
5302            let tenant_id = solo_core::TenantId::default_tenant();
5303            let tenant_handle = StdArc::new(
5304                TenantHandle::from_parts_for_tests(
5305                    tenant_id.clone(),
5306                    fake_config(dim as u32),
5307                    path.clone(),
5308                    tmp.path().to_path_buf(),
5309                    embedder_id,
5310                    hnsw,
5311                    embedder.clone(),
5312                    handle.clone(),
5313                    // The harness owns ANOTHER WriteHandle clone + the join.
5314                    // We give the TenantHandle a dummy join that immediately
5315                    // returns — it never gets joined because shutdown_all
5316                    // can't get exclusive Arc ownership when the harness
5317                    // also holds a writer clone.
5318                    std::thread::spawn(|| {}),
5319                    pool,
5320                ),
5321            );
5322            let tenant_handle_clone = tenant_handle.clone();
5323
5324            // Suppress the auto-spawned dummy thread by letting it finish.
5325            // We DON'T put the real `join` into the TenantHandle because
5326            // we keep our own clone of `handle` for the shutdown path.
5327            let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
5328            let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
5329                tmp.path().to_path_buf(),
5330                key,
5331                embedder,
5332                tenant_handle,
5333            ));
5334            let registry_clone = registry.clone();
5335
5336            let state = SoloHttpState {
5337                registry,
5338                default_tenant: tenant_id,
5339                user_aliases: Arc::new(Vec::new()),
5340            };
5341            let router = router_with_auth_config(state, auth);
5342            Harness {
5343                router,
5344                _tmp: tmp,
5345                db_path: path,
5346                write_handle_extra: Some(handle),
5347                join: Some(join),
5348                tenant_handle: tenant_handle_clone,
5349                registry: registry_clone,
5350            }
5351        }
5352
5353        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
5354            let join = self.join.take();
5355            let extra = self.write_handle_extra.take();
5356            // v0.10.0: the new `tenant_handle` Harness field holds another
5357            // `Arc<TenantHandle>` that owns its own WriteHandle clone.
5358            // We must drop our reference here so the inner WriteHandle
5359            // can be released when the registry drops below. Without
5360            // this, the writer thread's mpsc never closes and the join
5361            // times out at 5s.
5362            let tenant_handle = self.tenant_handle;
5363            // v0.10.0: same story for the new `registry` Arc clone the
5364            // tenants-list tests use to seed extra index rows — the
5365            // state inside the router holds one Arc, this is the
5366            // other; both must drop before the underlying registry
5367            // dies and releases its index-mutex / cached handles.
5368            let registry = self.registry;
5369            runtime.block_on(async move {
5370                drop(extra);
5371                drop(tenant_handle); // drop Harness's direct tenant Arc
5372                drop(registry); // drop Harness's direct registry Arc
5373                drop(self.router); // drops state → drops pool inside runtime ctx
5374                drop(self._tmp);
5375                if let Some(join) = join {
5376                    let (tx, rx) = std::sync::mpsc::channel();
5377                    std::thread::spawn(move || {
5378                        let _ = tx.send(join.join());
5379                    });
5380                    tokio::task::spawn_blocking(move || {
5381                        rx.recv_timeout(std::time::Duration::from_secs(5))
5382                    })
5383                    .await
5384                    .expect("blocking task")
5385                    .expect("writer thread did not exit within 5s")
5386                    .expect("writer thread panicked");
5387                }
5388            });
5389        }
5390    }
5391
5392    fn rt() -> tokio::runtime::Runtime {
5393        tokio::runtime::Builder::new_multi_thread()
5394            .worker_threads(2)
5395            .enable_all()
5396            .build()
5397            .unwrap()
5398    }
5399
5400    /// Issue one HTTP request through the router and capture status +
5401    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
5402    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
5403    async fn call(
5404        router: axum::Router,
5405        method: &str,
5406        uri: &str,
5407        body: Option<Value>,
5408    ) -> (StatusCode, Value) {
5409        call_with_auth(router, method, uri, body, None).await
5410    }
5411
5412    async fn call_with_auth(
5413        router: axum::Router,
5414        method: &str,
5415        uri: &str,
5416        body: Option<Value>,
5417        auth: Option<&str>,
5418    ) -> (StatusCode, Value) {
5419        let mut req_builder = Request::builder()
5420            .method(method)
5421            .uri(uri)
5422            .header("content-type", "application/json");
5423        if let Some(a) = auth {
5424            req_builder = req_builder.header("authorization", a);
5425        }
5426        let req = if let Some(b) = body {
5427            let bytes = serde_json::to_vec(&b).unwrap();
5428            req_builder.body(Body::from(bytes)).unwrap()
5429        } else {
5430            req_builder = req_builder.header("content-length", "0");
5431            req_builder.body(Body::empty()).unwrap()
5432        };
5433        let resp = router.oneshot(req).await.expect("oneshot");
5434        let status = resp.status();
5435        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
5436        let v: Value = if body_bytes.is_empty() {
5437            Value::Null
5438        } else {
5439            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
5440        };
5441        (status, v)
5442    }
5443
5444    #[test]
5445    fn health_returns_ok() {
5446        let runtime = rt();
5447        let h = Harness::new(&runtime);
5448        let r = h.router.clone();
5449        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
5450        assert_eq!(status, StatusCode::OK);
5451        h.shutdown(&runtime);
5452    }
5453
5454    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
5455    /// the four `memory.*` endpoints + their request/response schemas.
5456    /// Acts as a drift detector: if a future commit adds/removes a route
5457    /// without updating `openapi_spec`, this test fails loudly.
5458    #[test]
5459    fn openapi_json_describes_all_endpoints() {
5460        let runtime = rt();
5461        let h = Harness::new(&runtime);
5462        let r = h.router.clone();
5463        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
5464        assert_eq!(status, StatusCode::OK);
5465        assert!(spec.is_object(), "openapi.json must be a JSON object");
5466
5467        // Top-level shape per OpenAPI 3.1.
5468        assert!(
5469            spec.get("openapi")
5470                .and_then(|v| v.as_str())
5471                .is_some_and(|s| s.starts_with("3.")),
5472            "missing or wrong openapi version: {spec}"
5473        );
5474        assert!(spec.pointer("/info/title").is_some());
5475        assert!(spec.pointer("/info/version").is_some());
5476
5477        // Every route the router serves must be documented.
5478        let paths = spec
5479            .get("paths")
5480            .and_then(|v| v.as_object())
5481            .expect("paths must be an object");
5482        for expected in [
5483            "/health",
5484            "/openapi.json",
5485            "/memory",
5486            "/memory/search",
5487            "/memory/consolidate",
5488            "/memory/{id}",
5489            // Path 1 derived-layer endpoints (v0.4.0+):
5490            "/memory/themes",
5491            "/memory/facts_about",
5492            "/memory/contradictions",
5493            // v0.5.0 Priority 3:
5494            "/memory/clusters/{cluster_id}",
5495            // v0.7.0 P6 — document operations:
5496            "/memory/documents",
5497            "/memory/documents/search",
5498            "/memory/documents/{id}",
5499        ] {
5500            assert!(
5501                paths.contains_key(expected),
5502                "openapi paths missing {expected}: {paths:?}"
5503            );
5504        }
5505
5506        // Method coverage on /memory/documents: must document both POST
5507        // (ingest) and GET (list).
5508        let docs = paths.get("/memory/documents").expect("/memory/documents");
5509        assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
5510        assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
5511
5512        // Method coverage on /memory/documents/{id}: must document both
5513        // GET (inspect) and DELETE (forget).
5514        let docid = paths
5515            .get("/memory/documents/{id}")
5516            .expect("/memory/documents/{id}");
5517        assert!(
5518            docid.get("get").is_some(),
5519            "GET /memory/documents/{{id}} undocumented"
5520        );
5521        assert!(
5522            docid.get("delete").is_some(),
5523            "DELETE /memory/documents/{{id}} undocumented"
5524        );
5525
5526        // Method coverage on /memory/{id}: must document both GET (inspect)
5527        // and DELETE (forget).
5528        let memid = paths.get("/memory/{id}").expect("memory/{id}");
5529        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
5530        assert!(
5531            memid.get("delete").is_some(),
5532            "DELETE /memory/{{id}} undocumented"
5533        );
5534
5535        // Component schemas referenced from paths must be defined.
5536        for schema_name in [
5537            "RememberRequest",
5538            "RememberResponse",
5539            "RecallRequest",
5540            "RecallResult",
5541            "EpisodeRecord",
5542            "ApiError",
5543            "ConsolidationScope",
5544            "ConsolidationReport",
5545            // Path 1 derived-layer schemas (v0.4.0+):
5546            "ThemeHit",
5547            "FactHit",
5548            "ContradictionHit",
5549            // v0.5.0 Priority 3:
5550            "ClusterRecord",
5551            // v0.7.0 P6 — document schemas:
5552            "IngestDocumentRequest",
5553            "IngestReport",
5554            "ForgetDocumentReport",
5555            "SearchDocsRequest",
5556            "DocSearchHit",
5557            "DocumentInspectResult",
5558            "DocumentSummary",
5559        ] {
5560            let ptr = format!("/components/schemas/{schema_name}");
5561            assert!(
5562                spec.pointer(&ptr).is_some(),
5563                "component schema {schema_name} missing"
5564            );
5565        }
5566
5567        // bearerAuth security scheme is declared (LAN deployments need it).
5568        assert!(
5569            spec.pointer("/components/securitySchemes/bearerAuth")
5570                .is_some(),
5571            "bearerAuth security scheme missing"
5572        );
5573
5574        h.shutdown(&runtime);
5575    }
5576
5577    /// `/openapi.json` must remain unauthenticated even when bearer auth
5578    /// is enabled — the spec describes the API shape, not secrets, and
5579    /// codegen tooling shouldn't need a credential to fetch it.
5580    #[test]
5581    fn openapi_json_is_exempt_from_bearer_auth() {
5582        let runtime = rt();
5583        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
5584        let r = h.router.clone();
5585        // No Authorization header → still 200 for /openapi.json.
5586        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
5587        assert_eq!(status, StatusCode::OK);
5588        h.shutdown(&runtime);
5589    }
5590
5591    #[test]
5592    fn remember_returns_memory_id() {
5593        let runtime = rt();
5594        let h = Harness::new(&runtime);
5595        let r = h.router.clone();
5596        let (status, body) = runtime.block_on(call(
5597            r,
5598            "POST",
5599            "/memory",
5600            Some(json!({ "content": "http harness test" })),
5601        ));
5602        assert_eq!(status, StatusCode::OK);
5603        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
5604        assert_eq!(mid.len(), 36, "uuid length");
5605        h.shutdown(&runtime);
5606    }
5607
5608    #[test]
5609    fn empty_content_returns_400() {
5610        let runtime = rt();
5611        let h = Harness::new(&runtime);
5612        let r = h.router.clone();
5613        let (status, body) =
5614            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
5615        assert_eq!(status, StatusCode::BAD_REQUEST);
5616        assert!(
5617            body.get("error")
5618                .and_then(|e| e.as_str())
5619                .map(|s| s.contains("must not be empty"))
5620                .unwrap_or(false),
5621            "got: {body}"
5622        );
5623        h.shutdown(&runtime);
5624    }
5625
5626    #[test]
5627    fn empty_query_returns_400() {
5628        let runtime = rt();
5629        let h = Harness::new(&runtime);
5630        let r = h.router.clone();
5631        let (status, body) = runtime.block_on(call(
5632            r,
5633            "POST",
5634            "/memory/search",
5635            Some(json!({ "query": "" })),
5636        ));
5637        assert_eq!(status, StatusCode::BAD_REQUEST);
5638        assert!(
5639            body.get("error")
5640                .and_then(|e| e.as_str())
5641                .map(|s| s.contains("must not be empty"))
5642                .unwrap_or(false),
5643            "got: {body}"
5644        );
5645        h.shutdown(&runtime);
5646    }
5647
5648    #[test]
5649    fn inspect_unknown_returns_404() {
5650        let runtime = rt();
5651        let h = Harness::new(&runtime);
5652        let r = h.router.clone();
5653        let (status, body) = runtime.block_on(call(
5654            r,
5655            "GET",
5656            "/memory/00000000-0000-7000-8000-000000000000",
5657            None,
5658        ));
5659        assert_eq!(status, StatusCode::NOT_FOUND);
5660        assert!(body.get("error").is_some(), "got: {body}");
5661        h.shutdown(&runtime);
5662    }
5663
5664    #[test]
5665    fn inspect_invalid_id_returns_400() {
5666        let runtime = rt();
5667        let h = Harness::new(&runtime);
5668        let r = h.router.clone();
5669        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
5670        assert_eq!(status, StatusCode::BAD_REQUEST);
5671        h.shutdown(&runtime);
5672    }
5673
5674    #[test]
5675    fn forget_unknown_returns_404() {
5676        let runtime = rt();
5677        let h = Harness::new(&runtime);
5678        let r = h.router.clone();
5679        let (status, _body) = runtime.block_on(call(
5680            r,
5681            "DELETE",
5682            "/memory/00000000-0000-7000-8000-000000000000",
5683            None,
5684        ));
5685        assert_eq!(status, StatusCode::NOT_FOUND);
5686        h.shutdown(&runtime);
5687    }
5688
5689    /// `POST /memory/consolidate` runs the cluster pass and returns
5690    /// the report as JSON. With an empty body, `ConsolidationScope`
5691    /// defaults to unbounded; with a non-empty body, the
5692    /// `window_days` field is honored. The Harness's writer is
5693    /// spawned without a Steward, so `abstractions_built` stays 0
5694    /// even when `clusters_built` is nonzero — same posture as the
5695    /// daemon today.
5696    #[test]
5697    fn consolidate_endpoint_returns_report() {
5698        let runtime = rt();
5699        let h = Harness::new(&runtime);
5700        let r = h.router.clone();
5701        runtime.block_on(async move {
5702            // Empty DB → all-zero report; structural assertion only.
5703            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
5704            assert_eq!(status, StatusCode::OK);
5705            for field in [
5706                "episodes_seen",
5707                "clusters_built",
5708                "episodes_clustered",
5709                "abstractions_built",
5710                "triples_built",
5711                "contradictions_found",
5712            ] {
5713                assert!(
5714                    body.get(field).and_then(|v| v.as_u64()).is_some(),
5715                    "missing field {field}: {body}"
5716                );
5717            }
5718            assert_eq!(body["episodes_seen"], 0);
5719            assert_eq!(body["clusters_built"], 0);
5720
5721            // Non-empty body with window_days → still 200; unmistakable
5722            // shape round-trips through ConsolidationScope's serde.
5723            let (status2, _body2) = call(
5724                r,
5725                "POST",
5726                "/memory/consolidate",
5727                Some(json!({ "window_days": 7 })),
5728            )
5729            .await;
5730            assert_eq!(status2, StatusCode::OK);
5731        });
5732        h.shutdown(&runtime);
5733    }
5734
5735    #[test]
5736    fn auth_required_routes_reject_missing_token() {
5737        let runtime = rt();
5738        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
5739        let r = h.router.clone();
5740        runtime.block_on(async move {
5741            // No Authorization header → 401.
5742            let (status, _body) = call(
5743                r.clone(),
5744                "POST",
5745                "/memory",
5746                Some(json!({ "content": "x" })),
5747            )
5748            .await;
5749            assert_eq!(status, StatusCode::UNAUTHORIZED);
5750
5751            // Wrong token → 401.
5752            let (status, _body) = call_with_auth(
5753                r.clone(),
5754                "POST",
5755                "/memory",
5756                Some(json!({ "content": "x" })),
5757                Some("Bearer wrong-token"),
5758            )
5759            .await;
5760            assert_eq!(status, StatusCode::UNAUTHORIZED);
5761
5762            // Correct token → handler runs (200).
5763            let (status, body) = call_with_auth(
5764                r.clone(),
5765                "POST",
5766                "/memory",
5767                Some(json!({ "content": "authed" })),
5768                Some("Bearer secret-xyz"),
5769            )
5770            .await;
5771            assert_eq!(status, StatusCode::OK);
5772            assert!(body.get("memory_id").is_some());
5773        });
5774        h.shutdown(&runtime);
5775    }
5776
5777    #[test]
5778    fn health_endpoint_does_not_require_auth() {
5779        let runtime = rt();
5780        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
5781        let r = h.router.clone();
5782        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
5783        // Liveness probes should work without credentials.
5784        assert_eq!(status, StatusCode::OK);
5785        h.shutdown(&runtime);
5786    }
5787
5788    #[test]
5789    fn auth_response_includes_www_authenticate_header() {
5790        // Verify the WWW-Authenticate hint that lets a well-behaved
5791        // client know it's a bearer-auth scheme. We check via raw
5792        // request → response (oneshot returns Response, but our
5793        // call() helper drops the headers; build the request manually).
5794        let runtime = rt();
5795        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
5796        let r = h.router.clone();
5797        runtime.block_on(async move {
5798            let req = Request::builder()
5799                .method("POST")
5800                .uri("/memory")
5801                .header("content-type", "application/json")
5802                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
5803                .unwrap();
5804            let resp = r.oneshot(req).await.unwrap();
5805            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
5806            let www = resp
5807                .headers()
5808                .get("www-authenticate")
5809                .and_then(|v| v.to_str().ok())
5810                .unwrap_or("");
5811            assert!(
5812                www.starts_with("Bearer"),
5813                "expected WWW-Authenticate: Bearer..., got: {www}"
5814            );
5815        });
5816        h.shutdown(&runtime);
5817    }
5818
5819    // ---------------------------------------------------------------------
5820    // v0.8.0 P3: OIDC end-to-end. Spin up a fake IdP (wiremock) that
5821    // serves an OIDC discovery doc + JWKS, mint a token claiming
5822    // `solo_tenant = "default"`, and verify it routes through the
5823    // middleware + TenantExtractor + handler.
5824    // ---------------------------------------------------------------------
5825
5826    fn base64_url_for_test(bytes: &[u8]) -> String {
5827        use base64::Engine;
5828        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
5829    }
5830
5831    /// Spin up a single-purpose fake OIDC IdP for these tests. Returns
5832    /// (mock_server, discovery_url, secret, kid).
5833    async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
5834        use wiremock::matchers::{method, path};
5835        use wiremock::{Mock, MockServer, ResponseTemplate};
5836        let server = MockServer::start().await;
5837        let secret = b"http-test-secret-for-hmac-fixture".to_vec();
5838        let kid = "http-test-kid";
5839        let discovery = serde_json::json!({
5840            "issuer": server.uri(),
5841            "jwks_uri": format!("{}/jwks", server.uri()),
5842        });
5843        Mock::given(method("GET"))
5844            .and(path("/.well-known/openid-configuration"))
5845            .respond_with(ResponseTemplate::new(200).set_body_json(discovery))
5846            .mount(&server)
5847            .await;
5848        let jwks = serde_json::json!({
5849            "keys": [
5850                {
5851                    "kty": "oct",
5852                    "kid": kid,
5853                    "alg": "HS256",
5854                    "k": base64_url_for_test(&secret),
5855                }
5856            ]
5857        });
5858        Mock::given(method("GET"))
5859            .and(path("/jwks"))
5860            .respond_with(ResponseTemplate::new(200).set_body_json(jwks))
5861            .mount(&server)
5862            .await;
5863        let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
5864        (server, discovery_url, secret, kid)
5865    }
5866
5867    fn mint_idp_token(
5868        server_uri: &str,
5869        kid: &str,
5870        secret: &[u8],
5871        tenant_claim: &str,
5872        audience: &str,
5873    ) -> String {
5874        use jsonwebtoken::{Algorithm, EncodingKey, Header};
5875        let mut header = Header::new(Algorithm::HS256);
5876        header.kid = Some(kid.to_string());
5877        let now = std::time::SystemTime::now()
5878            .duration_since(std::time::UNIX_EPOCH)
5879            .unwrap()
5880            .as_secs();
5881        let claims = serde_json::json!({
5882            "iss": server_uri,
5883            "sub": "test-user-1",
5884            "aud": audience,
5885            "exp": now + 600,
5886            "iat": now,
5887            "solo_tenant": tenant_claim,
5888        });
5889        jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
5890            .expect("mint token")
5891    }
5892
5893    #[test]
5894    fn http_oidc_accept_resolves_to_tenant_from_claim() {
5895        let runtime = rt();
5896        let (fake_server, discovery_url, secret, kid) =
5897            runtime.block_on(async { spin_fake_idp().await });
5898        let server_uri = fake_server.uri();
5899        // Keep the wiremock server alive for the duration of this test.
5900        let _server_guard = fake_server;
5901
5902        let auth = crate::auth::AuthConfig::Oidc {
5903            discovery_url,
5904            audience: "test-audience".to_string(),
5905            tenant_claim_name: "solo_tenant".to_string(),
5906        };
5907        let h = Harness::new_with_auth_config(&runtime, Some(auth));
5908        let r = h.router.clone();
5909
5910        // Mint a token claiming the harness's default tenant.
5911        let token = mint_idp_token(
5912            &server_uri,
5913            kid,
5914            &secret,
5915            "default",
5916            "test-audience",
5917        );
5918
5919        runtime.block_on(async move {
5920            // POST /memory with a valid OIDC token → handler runs, returns memory_id.
5921            let (status, body) = call_with_auth(
5922                r.clone(),
5923                "POST",
5924                "/memory",
5925                Some(json!({ "content": "oidc-routed content" })),
5926                Some(&format!("Bearer {token}")),
5927            )
5928            .await;
5929            assert_eq!(status, StatusCode::OK, "got body: {body}");
5930            assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
5931        });
5932        h.shutdown(&runtime);
5933    }
5934
5935    #[test]
5936    fn http_oidc_reject_missing_token_returns_401() {
5937        let runtime = rt();
5938        let (fake_server, discovery_url, _secret, _kid) =
5939            runtime.block_on(async { spin_fake_idp().await });
5940        let _server_guard = fake_server;
5941        let auth = crate::auth::AuthConfig::Oidc {
5942            discovery_url,
5943            audience: "test-audience".to_string(),
5944            tenant_claim_name: "solo_tenant".to_string(),
5945        };
5946        let h = Harness::new_with_auth_config(&runtime, Some(auth));
5947        let r = h.router.clone();
5948        runtime.block_on(async move {
5949            // No Authorization header.
5950            let (status, _body) =
5951                call(r.clone(), "POST", "/memory", Some(json!({ "content": "x" }))).await;
5952            assert_eq!(status, StatusCode::UNAUTHORIZED);
5953
5954            // Garbage token → 401 (invalid signature / not a JWT).
5955            let (status, _body) = call_with_auth(
5956                r.clone(),
5957                "POST",
5958                "/memory",
5959                Some(json!({ "content": "x" })),
5960                Some("Bearer not-a-real-jwt"),
5961            )
5962            .await;
5963            assert_eq!(status, StatusCode::UNAUTHORIZED);
5964        });
5965        h.shutdown(&runtime);
5966    }
5967
5968    #[test]
5969    fn full_remember_recall_inspect_forget_round_trip() {
5970        let runtime = rt();
5971        let h = Harness::new(&runtime);
5972        let r = h.router.clone();
5973        runtime.block_on(async move {
5974            // POST /memory
5975            let (status, body) = call(
5976                r.clone(),
5977                "POST",
5978                "/memory",
5979                Some(json!({ "content": "round-trip content" })),
5980            )
5981            .await;
5982            assert_eq!(status, StatusCode::OK);
5983            let mid = body
5984                .get("memory_id")
5985                .and_then(|v| v.as_str())
5986                .unwrap()
5987                .to_string();
5988
5989            // POST /memory/search — exact-match (StubEmbedder) returns the row.
5990            let (status, body) = call(
5991                r.clone(),
5992                "POST",
5993                "/memory/search",
5994                Some(json!({ "query": "round-trip content", "limit": 5 })),
5995            )
5996            .await;
5997            assert_eq!(status, StatusCode::OK);
5998            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
5999            assert!(
6000                hits.iter()
6001                    .any(|h| h.get("content").and_then(|c| c.as_str())
6002                        == Some("round-trip content")),
6003                "expected hit with content; got: {body}"
6004            );
6005
6006            // GET /memory/{id}
6007            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
6008            assert_eq!(status, StatusCode::OK);
6009            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
6010
6011            // DELETE /memory/{id}
6012            let (status, _body) =
6013                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
6014            assert_eq!(status, StatusCode::NO_CONTENT);
6015
6016            // GET again — still readable but status='forgotten'
6017            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
6018            assert_eq!(status, StatusCode::OK);
6019            assert_eq!(
6020                body.get("status").and_then(|v| v.as_str()),
6021                Some("forgotten")
6022            );
6023
6024            // POST /memory/search — forgotten row excluded.
6025            let (status, body) = call(
6026                r.clone(),
6027                "POST",
6028                "/memory/search",
6029                Some(json!({ "query": "round-trip content", "limit": 5 })),
6030            )
6031            .await;
6032            assert_eq!(status, StatusCode::OK);
6033            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
6034            assert!(
6035                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
6036                    != Some(mid.as_str())),
6037                "forgotten row should be excluded from recall: {body}"
6038            );
6039        });
6040        h.shutdown(&runtime);
6041    }
6042
6043    // Path 1 derived-layer endpoint tests (v0.4.0+). Wire-path only —
6044    // the actual content correctness is covered by solo-query::derived's
6045    // own tests (Sub-task A). These verify the HTTP shape: GET routing,
6046    // Query-string param parsing, JSON-array response body, validation
6047    // 400s for invalid inputs.
6048
6049    #[test]
6050    fn themes_endpoint_returns_empty_array_on_empty_db() {
6051        let runtime = rt();
6052        let h = Harness::new(&runtime);
6053        let r = h.router.clone();
6054        let (status, body) =
6055            runtime.block_on(call(r, "GET", "/memory/themes", None));
6056        assert_eq!(status, StatusCode::OK);
6057        assert!(body.is_array(), "expected array, got {body}");
6058        assert_eq!(body.as_array().unwrap().len(), 0);
6059        h.shutdown(&runtime);
6060    }
6061
6062    #[test]
6063    fn themes_endpoint_passes_through_query_params() {
6064        let runtime = rt();
6065        let h = Harness::new(&runtime);
6066        let r = h.router.clone();
6067        let (status, body) = runtime.block_on(call(
6068            r,
6069            "GET",
6070            "/memory/themes?window_days=7&limit=20",
6071            None,
6072        ));
6073        assert_eq!(status, StatusCode::OK);
6074        assert!(body.is_array(), "expected array, got {body}");
6075        h.shutdown(&runtime);
6076    }
6077
6078    #[test]
6079    fn facts_about_endpoint_requires_subject() {
6080        let runtime = rt();
6081        let h = Harness::new(&runtime);
6082        let r = h.router.clone();
6083        // Missing subject — axum's Query extractor 422 (Unprocessable
6084        // Entity) on missing required field; some axum versions
6085        // surface as 400. Accept either.
6086        let (status, _body) =
6087            runtime.block_on(call(r, "GET", "/memory/facts_about", None));
6088        assert!(
6089            status == StatusCode::BAD_REQUEST
6090                || status == StatusCode::UNPROCESSABLE_ENTITY,
6091            "expected 400 or 422 for missing subject, got {status}"
6092        );
6093        h.shutdown(&runtime);
6094    }
6095
6096    #[test]
6097    fn facts_about_endpoint_rejects_blank_subject() {
6098        let runtime = rt();
6099        let h = Harness::new(&runtime);
6100        let r = h.router.clone();
6101        // Whitespace-only subject reaches the handler then trips its
6102        // own validation → ApiError::bad_request → 400.
6103        let (status, body) = runtime.block_on(call(
6104            r,
6105            "GET",
6106            "/memory/facts_about?subject=%20%20",
6107            None,
6108        ));
6109        assert_eq!(status, StatusCode::BAD_REQUEST);
6110        assert!(
6111            body.get("error")
6112                .and_then(|v| v.as_str())
6113                .is_some_and(|s| s.contains("subject")),
6114            "expected error mentioning subject, got {body}"
6115        );
6116        h.shutdown(&runtime);
6117    }
6118
6119    #[test]
6120    fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
6121        let runtime = rt();
6122        let h = Harness::new(&runtime);
6123        let r = h.router.clone();
6124        let (status, body) = runtime.block_on(call(
6125            r,
6126            "GET",
6127            "/memory/facts_about?subject=NobodyKnows",
6128            None,
6129        ));
6130        assert_eq!(status, StatusCode::OK);
6131        assert_eq!(body.as_array().unwrap().len(), 0);
6132        h.shutdown(&runtime);
6133    }
6134
6135    #[test]
6136    fn facts_about_endpoint_parses_include_as_object_query_param() {
6137        // v0.5.1 P8: `?include_as_object=true` must parse cleanly
6138        // through the `Query<FactsAboutQuery>` extractor. If the
6139        // struct field is missing or wrongly typed, axum returns
6140        // 400/422 before reaching the handler. We don't seed
6141        // triples; we only need the request to reach the handler
6142        // and produce a normal 200 + empty array. Mirrors
6143        // `inspect_cluster_endpoint_passes_full_content_query_param`.
6144        let runtime = rt();
6145        let h = Harness::new(&runtime);
6146        let r = h.router.clone();
6147        let (status, body) = runtime.block_on(call(
6148            r,
6149            "GET",
6150            "/memory/facts_about?subject=Maya&include_as_object=true",
6151            None,
6152        ));
6153        assert_eq!(
6154            status,
6155            StatusCode::OK,
6156            "expected 200 with include_as_object query param, got {status}"
6157        );
6158        assert!(body.is_array());
6159        h.shutdown(&runtime);
6160    }
6161
6162    #[test]
6163    fn inspect_cluster_endpoint_unknown_id_returns_404() {
6164        // Maps `Error::NotFound` from `solo_query::inspect_cluster`
6165        // through `ApiError::from` → 404. Mirrors the unknown-memory
6166        // case for `GET /memory/{id}`.
6167        let runtime = rt();
6168        let h = Harness::new(&runtime);
6169        let r = h.router.clone();
6170        let (status, body) = runtime.block_on(call(
6171            r,
6172            "GET",
6173            "/memory/clusters/no-such-cluster",
6174            None,
6175        ));
6176        assert_eq!(status, StatusCode::NOT_FOUND);
6177        assert!(
6178            body.get("error")
6179                .and_then(|v| v.as_str())
6180                .is_some_and(|s| s.contains("no-such-cluster")),
6181            "expected error mentioning cluster id, got {body}"
6182        );
6183        h.shutdown(&runtime);
6184    }
6185
6186    #[test]
6187    fn inspect_cluster_endpoint_passes_full_content_query_param() {
6188        // Even with no matching cluster (→ 404), the request must
6189        // reach the handler — proves the `?full_content=true` query
6190        // string parses cleanly (Query<InspectClusterQuery>::default
6191        // path didn't choke). If we accidentally fail at the extractor
6192        // we'd get a 400/422, not the expected 404.
6193        let runtime = rt();
6194        let h = Harness::new(&runtime);
6195        let r = h.router.clone();
6196        let (status, _body) = runtime.block_on(call(
6197            r,
6198            "GET",
6199            "/memory/clusters/missing?full_content=true",
6200            None,
6201        ));
6202        assert_eq!(status, StatusCode::NOT_FOUND);
6203        h.shutdown(&runtime);
6204    }
6205
6206    #[test]
6207    fn contradictions_endpoint_returns_empty_array_on_empty_db() {
6208        let runtime = rt();
6209        let h = Harness::new(&runtime);
6210        let r = h.router.clone();
6211        let (status, body) = runtime.block_on(call(
6212            r,
6213            "GET",
6214            "/memory/contradictions",
6215            None,
6216        ));
6217        assert_eq!(status, StatusCode::OK);
6218        assert!(body.is_array());
6219        assert_eq!(body.as_array().unwrap().len(), 0);
6220        h.shutdown(&runtime);
6221    }
6222
6223    #[test]
6224    fn derived_endpoints_require_bearer_when_auth_enabled() {
6225        let runtime = rt();
6226        let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
6227        // Each of the three new endpoints should reject missing token.
6228        // Per the existing tests' shutdown-timing comment: don't hold a
6229        // long-lived router clone across multiple iterations — drop the
6230        // clone before each subsequent oneshot, and don't keep a `let r =
6231        // h.router.clone()` alive across h.shutdown(). Re-clone per
6232        // iteration; the per-call clone is consumed by oneshot.
6233        for path in [
6234            "/memory/themes",
6235            "/memory/facts_about?subject=Sam",
6236            "/memory/contradictions",
6237            "/memory/clusters/any-id",
6238        ] {
6239            let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
6240            assert_eq!(
6241                status,
6242                StatusCode::UNAUTHORIZED,
6243                "{path} should 401 without token"
6244            );
6245        }
6246        h.shutdown(&runtime);
6247    }
6248
6249    // ---- Document endpoints (v0.7.0 P6) ----
6250    //
6251    // Wire-path coverage. The `Harness` here uses
6252    // `WriterActor::spawn_full` without an embedder — same shape as the
6253    // existing handler tests. Ingest/search would fail at the writer
6254    // boundary with "writer has no embedder", but every other path
6255    // (404s, malformed ids, route shape, bearer auth gating, OpenAPI
6256    // documentation) is exercisable. Real end-to-end ingest→search
6257    // round-trip lives in `mcp_smoke.rs` where a real subprocess runs
6258    // with a fully-wired writer.
6259
6260    #[test]
6261    fn list_documents_endpoint_returns_empty_array_on_empty_db() {
6262        let runtime = rt();
6263        let h = Harness::new(&runtime);
6264        let r = h.router.clone();
6265        let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
6266        assert_eq!(status, StatusCode::OK);
6267        assert!(body.is_array(), "expected array, got {body}");
6268        assert_eq!(body.as_array().unwrap().len(), 0);
6269        h.shutdown(&runtime);
6270    }
6271
6272    #[test]
6273    fn list_documents_endpoint_parses_query_params() {
6274        let runtime = rt();
6275        let h = Harness::new(&runtime);
6276        let r = h.router.clone();
6277        let (status, body) = runtime.block_on(call(
6278            r,
6279            "GET",
6280            "/memory/documents?limit=5&offset=0&include_forgotten=true",
6281            None,
6282        ));
6283        assert_eq!(status, StatusCode::OK);
6284        assert!(body.is_array());
6285        h.shutdown(&runtime);
6286    }
6287
6288    #[test]
6289    fn ingest_document_endpoint_rejects_empty_path() {
6290        let runtime = rt();
6291        let h = Harness::new(&runtime);
6292        let r = h.router.clone();
6293        let (status, body) = runtime.block_on(call(
6294            r,
6295            "POST",
6296            "/memory/documents",
6297            Some(json!({ "path": "" })),
6298        ));
6299        assert_eq!(status, StatusCode::BAD_REQUEST);
6300        assert!(
6301            body.get("error")
6302                .and_then(|v| v.as_str())
6303                .is_some_and(|s| s.contains("path")),
6304            "expected error mentioning path, got {body}"
6305        );
6306        h.shutdown(&runtime);
6307    }
6308
6309    #[test]
6310    fn search_docs_endpoint_rejects_empty_query() {
6311        let runtime = rt();
6312        let h = Harness::new(&runtime);
6313        let r = h.router.clone();
6314        let (status, body) = runtime.block_on(call(
6315            r,
6316            "POST",
6317            "/memory/documents/search",
6318            Some(json!({ "query": "   " })),
6319        ));
6320        assert_eq!(status, StatusCode::BAD_REQUEST);
6321        assert!(
6322            body.get("error")
6323                .and_then(|v| v.as_str())
6324                .is_some_and(|s| s.contains("must not be empty")
6325                    || s.contains("doc_search")),
6326            "expected error mentioning empty query, got {body}"
6327        );
6328        h.shutdown(&runtime);
6329    }
6330
6331    #[test]
6332    fn inspect_document_endpoint_unknown_id_returns_404() {
6333        let runtime = rt();
6334        let h = Harness::new(&runtime);
6335        let r = h.router.clone();
6336        let (status, body) = runtime.block_on(call(
6337            r,
6338            "GET",
6339            "/memory/documents/00000000-0000-7000-8000-000000000000",
6340            None,
6341        ));
6342        assert_eq!(status, StatusCode::NOT_FOUND);
6343        assert!(body.get("error").is_some(), "got: {body}");
6344        h.shutdown(&runtime);
6345    }
6346
6347    #[test]
6348    fn inspect_document_endpoint_rejects_malformed_id() {
6349        let runtime = rt();
6350        let h = Harness::new(&runtime);
6351        let r = h.router.clone();
6352        let (status, _body) =
6353            runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
6354        assert_eq!(status, StatusCode::BAD_REQUEST);
6355        h.shutdown(&runtime);
6356    }
6357
6358    #[test]
6359    fn forget_document_endpoint_unknown_id_returns_404() {
6360        // Valid UUID format; no row exists → writer's `forget_document`
6361        // returns Error::NotFound → mapped to 404 by `ApiError::from`.
6362        let runtime = rt();
6363        let h = Harness::new(&runtime);
6364        let r = h.router.clone();
6365        let (status, _body) = runtime.block_on(call(
6366            r,
6367            "DELETE",
6368            "/memory/documents/00000000-0000-7000-8000-000000000000",
6369            None,
6370        ));
6371        assert_eq!(status, StatusCode::NOT_FOUND);
6372        h.shutdown(&runtime);
6373    }
6374
6375    #[test]
6376    fn forget_document_endpoint_rejects_malformed_id() {
6377        let runtime = rt();
6378        let h = Harness::new(&runtime);
6379        let r = h.router.clone();
6380        let (status, _body) =
6381            runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
6382        assert_eq!(status, StatusCode::BAD_REQUEST);
6383        h.shutdown(&runtime);
6384    }
6385
6386    #[test]
6387    fn document_endpoints_require_bearer_when_auth_enabled() {
6388        // All five doc endpoints sit behind the same authed Router and
6389        // must 401 without the bearer token. Mirrors
6390        // `derived_endpoints_require_bearer_when_auth_enabled`.
6391        let runtime = rt();
6392        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
6393        let cases: &[(&str, &str, Option<Value>)] = &[
6394            ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
6395            ("GET", "/memory/documents", None),
6396            (
6397                "POST",
6398                "/memory/documents/search",
6399                Some(json!({ "query": "x" })),
6400            ),
6401            (
6402                "GET",
6403                "/memory/documents/00000000-0000-7000-8000-000000000000",
6404                None,
6405            ),
6406            (
6407                "DELETE",
6408                "/memory/documents/00000000-0000-7000-8000-000000000000",
6409                None,
6410            ),
6411        ];
6412        for (method, path, body) in cases {
6413            let (status, _) =
6414                runtime.block_on(call(h.router.clone(), method, path, body.clone()));
6415            assert_eq!(
6416                status,
6417                StatusCode::UNAUTHORIZED,
6418                "{method} {path} should 401 without token"
6419            );
6420        }
6421        h.shutdown(&runtime);
6422    }
6423
6424    #[test]
6425    fn document_endpoints_accept_correct_bearer_token() {
6426        // Sanity check: with the right token, the same five endpoints
6427        // pass auth and reach the handler. We only assert that the
6428        // status code is NOT 401 — exact downstream behaviour depends
6429        // on the harness (no embedder → ingest/search would 500; empty
6430        // DB → list/inspect/forget return 200/404).
6431        let runtime = rt();
6432        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
6433        runtime.block_on(async {
6434            // GET /memory/documents → 200 + empty array (auth passes).
6435            let (status, _) = call_with_auth(
6436                h.router.clone(),
6437                "GET",
6438                "/memory/documents",
6439                None,
6440                Some("Bearer doc-secret"),
6441            )
6442            .await;
6443            assert_eq!(status, StatusCode::OK);
6444
6445            // GET /memory/documents/<unknown> → 404 (auth passes).
6446            let (status, _) = call_with_auth(
6447                h.router.clone(),
6448                "GET",
6449                "/memory/documents/00000000-0000-7000-8000-000000000000",
6450                None,
6451                Some("Bearer doc-secret"),
6452            )
6453            .await;
6454            assert_eq!(status, StatusCode::NOT_FOUND);
6455        });
6456        h.shutdown(&runtime);
6457    }
6458
6459    // ---------------------------------------------------------------------
6460    // v0.8.0 P2: tenant header extractor tests
6461    // ---------------------------------------------------------------------
6462
6463    /// `X-Solo-Tenant: default` resolves to the default tenant (which
6464    /// in the test harness is the only one wired in the registry).
6465    #[test]
6466    fn tenant_header_default_resolves() {
6467        let runtime = rt();
6468        let h = Harness::new(&runtime);
6469        let r = h.router.clone();
6470        let (status, _body) = runtime.block_on(async {
6471            let req = Request::builder()
6472                .method("GET")
6473                .uri("/memory/00000000-0000-7000-8000-000000000000")
6474                .header("x-solo-tenant", "default")
6475                .body(Body::empty())
6476                .unwrap();
6477            let resp = r.oneshot(req).await.expect("oneshot");
6478            let s = resp.status();
6479            let _b = resp.into_body().collect().await.unwrap().to_bytes();
6480            (s, _b)
6481        });
6482        // 404 because the id doesn't exist — but it's a routed 404 from
6483        // inspect_handler, not a 400 from a bad tenant header. That's
6484        // the proof point.
6485        assert_eq!(status, StatusCode::NOT_FOUND);
6486        h.shutdown(&runtime);
6487    }
6488
6489    /// `X-Solo-Tenant: UPPER` → 400 (invalid tenant id format).
6490    #[test]
6491    fn tenant_header_invalid_returns_400() {
6492        let runtime = rt();
6493        let h = Harness::new(&runtime);
6494        let r = h.router.clone();
6495        let (status, body) = runtime.block_on(async {
6496            let req = Request::builder()
6497                .method("GET")
6498                .uri("/memory/00000000-0000-7000-8000-000000000000")
6499                .header("x-solo-tenant", "UPPER")
6500                .body(Body::empty())
6501                .unwrap();
6502            let resp = r.oneshot(req).await.expect("oneshot");
6503            let s = resp.status();
6504            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
6505            let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
6506            (s, v)
6507        });
6508        assert_eq!(status, StatusCode::BAD_REQUEST);
6509        let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
6510        assert!(
6511            msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
6512            "error must mention tenant/invalid: {msg}"
6513        );
6514        h.shutdown(&runtime);
6515    }
6516
6517    /// `X-Solo-Tenant: never-registered` → 404 (unknown tenant id).
6518    #[test]
6519    fn tenant_header_unknown_returns_404() {
6520        let runtime = rt();
6521        let h = Harness::new(&runtime);
6522        let r = h.router.clone();
6523        let (status, _body) = runtime.block_on(async {
6524            let req = Request::builder()
6525                .method("GET")
6526                .uri("/memory/00000000-0000-7000-8000-000000000000")
6527                .header("x-solo-tenant", "never-registered")
6528                .body(Body::empty())
6529                .unwrap();
6530            let resp = r.oneshot(req).await.expect("oneshot");
6531            let s = resp.status();
6532            let _b = resp.into_body().collect().await.unwrap().to_bytes();
6533            (s, _b)
6534        });
6535        assert_eq!(status, StatusCode::NOT_FOUND);
6536        h.shutdown(&runtime);
6537    }
6538
6539    /// No `X-Solo-Tenant` header → falls back to state.default_tenant.
6540    /// The reach-through to `inspect_handler` should produce the normal
6541    /// 404 for an unknown id rather than a tenant-routing error.
6542    #[test]
6543    fn tenant_header_missing_defaults_to_state_default_tenant() {
6544        let runtime = rt();
6545        let h = Harness::new(&runtime);
6546        let r = h.router.clone();
6547        let (status, _body) = runtime.block_on(async {
6548            let req = Request::builder()
6549                .method("GET")
6550                .uri("/memory/00000000-0000-7000-8000-000000000000")
6551                .body(Body::empty())
6552                .unwrap();
6553            let resp = r.oneshot(req).await.expect("oneshot");
6554            let s = resp.status();
6555            let _b = resp.into_body().collect().await.unwrap().to_bytes();
6556            (s, _b)
6557        });
6558        assert_eq!(status, StatusCode::NOT_FOUND);
6559        h.shutdown(&runtime);
6560    }
6561
6562    // ---------------------------------------------------------------------
6563    // v0.9.x: GET /v1/graph/expand
6564    //
6565    // Seeds tables directly via the Harness's side connection and walks
6566    // the four expansion kinds. The Harness is single-tenant (default);
6567    // the routing-isolation case is already covered by the
6568    // `tenant_header_*` tests above (an `X-Solo-Tenant: never-registered`
6569    // header against the same node_id surfaces 404 from the registry,
6570    // proving cross-tenant lookups can't bleed).
6571    // ---------------------------------------------------------------------
6572
6573    /// Insert one episode row directly. Returns its rowid for callers
6574    /// that need to wire `triples.source_episode_id`.
6575    fn seed_episode(
6576        conn: &rusqlite::Connection,
6577        memory_id: &str,
6578        ts_ms: i64,
6579        content: &str,
6580    ) -> i64 {
6581        conn.execute(
6582            "INSERT INTO episodes
6583                (memory_id, ts_ms, source_type, content,
6584                 encoding_context_json, tier, status,
6585                 confidence, strength, salience,
6586                 created_at_ms, updated_at_ms)
6587                VALUES (?1, ?2, 'user_message', ?3,
6588                        '{}', 'hot', 'active',
6589                        1.0, 0.5, 0.5, ?2, ?2)",
6590            rusqlite::params![memory_id, ts_ms, content],
6591        )
6592        .expect("seed episode");
6593        conn.last_insert_rowid()
6594    }
6595
6596    fn seed_cluster_row(conn: &rusqlite::Connection, cluster_id: &str, created_at_ms: i64) {
6597        conn.execute(
6598            "INSERT INTO clusters (cluster_id, coherence, created_at_ms)
6599                  VALUES (?1, 0.5, ?2)",
6600            rusqlite::params![cluster_id, created_at_ms],
6601        )
6602        .expect("seed cluster");
6603    }
6604
6605    fn seed_cluster_member(conn: &rusqlite::Connection, cluster_id: &str, memory_id: &str) {
6606        conn.execute(
6607            "INSERT INTO cluster_episodes (cluster_id, memory_id) VALUES (?1, ?2)",
6608            rusqlite::params![cluster_id, memory_id],
6609        )
6610        .expect("seed cluster_episodes");
6611    }
6612
6613    fn seed_document_row(conn: &rusqlite::Connection, doc_id: &str, title: &str) {
6614        conn.execute(
6615            "INSERT INTO documents
6616                (doc_id, source, title, mime_type, ingested_at_ms,
6617                 modified_at_ms, status, chunk_count, content_hash, byte_size)
6618                VALUES (?1, ?2, ?3, 'text/plain', 0, NULL,
6619                        'active', 0, ?1, NULL)",
6620            rusqlite::params![doc_id, format!("/tmp/{title}.txt"), title],
6621        )
6622        .expect("seed doc");
6623    }
6624
6625    fn seed_chunk_row(
6626        conn: &rusqlite::Connection,
6627        chunk_id: &str,
6628        doc_id: &str,
6629        chunk_index: i64,
6630        content: &str,
6631    ) {
6632        conn.execute(
6633            "INSERT INTO document_chunks
6634                (chunk_id, doc_id, chunk_index, content,
6635                 token_count, start_offset, end_offset, created_at_ms)
6636                VALUES (?1, ?2, ?3, ?4, 1, 0, ?5, 0)",
6637            rusqlite::params![chunk_id, doc_id, chunk_index, content, content.len() as i64],
6638        )
6639        .expect("seed chunk");
6640    }
6641
6642    fn seed_triple_row(
6643        conn: &rusqlite::Connection,
6644        triple_id: &str,
6645        subject: &str,
6646        predicate: &str,
6647        object: &str,
6648        source_episode_rowid: Option<i64>,
6649    ) {
6650        conn.execute(
6651            "INSERT INTO triples
6652                 (triple_id, subject_id, predicate, object_id, object_kind,
6653                  valid_from_ms, valid_to_ms, confidence, provenance_json,
6654                  status, created_at_ms, updated_at_ms, source_episode_id)
6655                 VALUES (?1, ?2, ?3, ?4, 'literal', 0, NULL, 0.9, '{}',
6656                         'active', 0, 0, ?5)",
6657            rusqlite::params![triple_id, subject, predicate, object, source_episode_rowid],
6658        )
6659        .expect("seed triple");
6660    }
6661
6662    /// Insert a `semantic_abstractions` row (cluster LLM summary). Used
6663    /// by the cluster-inspect test to verify the abstraction concat path.
6664    fn seed_abstraction_row(
6665        conn: &rusqlite::Connection,
6666        abstraction_id: &str,
6667        cluster_id: &str,
6668        content: &str,
6669    ) {
6670        conn.execute(
6671            "INSERT INTO semantic_abstractions
6672                 (abstraction_id, cluster_id, content, provenance_json,
6673                  confidence, created_at_ms)
6674                 VALUES (?1, ?2, ?3, '{}', 0.9, 0)",
6675            rusqlite::params![abstraction_id, cluster_id, content],
6676        )
6677        .expect("seed abstraction");
6678    }
6679
6680    /// Tests use simple ASCII node_ids (UUID-shaped + plain entity strings),
6681    /// so we percent-encode only `:` and a few other delimiters by hand.
6682    fn percent_encode_node_id(node_id: &str) -> String {
6683        let mut out = String::with_capacity(node_id.len());
6684        for c in node_id.chars() {
6685            match c {
6686                ':' => out.push_str("%3A"),
6687                ' ' => out.push_str("%20"),
6688                '&' => out.push_str("%26"),
6689                '+' => out.push_str("%2B"),
6690                '?' => out.push_str("%3F"),
6691                '#' => out.push_str("%23"),
6692                _ => out.push(c),
6693            }
6694        }
6695        out
6696    }
6697
6698    fn graph_uri(node_id: &str, kind: &str) -> String {
6699        let encoded = percent_encode_node_id(node_id);
6700        format!("/v1/graph/expand?node_id={encoded}&kind={kind}")
6701    }
6702
6703    fn graph_uri_with_limit(node_id: &str, kind: &str, limit: u32) -> String {
6704        let encoded = percent_encode_node_id(node_id);
6705        format!("/v1/graph/expand?node_id={encoded}&kind={kind}&limit={limit}")
6706    }
6707
6708    #[test]
6709    fn expand_cluster_member_from_episode_returns_clusters() {
6710        let runtime = rt();
6711        let h = Harness::new(&runtime);
6712        let memory_id = "11111111-1111-7000-8000-000000000001";
6713        {
6714            let conn = h.open_db();
6715            seed_episode(&conn, memory_id, 100, "ep content");
6716            seed_cluster_row(&conn, "cl-a", 200);
6717            seed_cluster_member(&conn, "cl-a", memory_id);
6718        }
6719        let node_id = format!("ep:{memory_id}");
6720        let (status, body) = runtime.block_on(call(
6721            h.router.clone(),
6722            "GET",
6723            &graph_uri(&node_id, "cluster_member"),
6724            None,
6725        ));
6726        assert_eq!(status, StatusCode::OK, "body: {body}");
6727        let nodes = body.get("nodes").and_then(|v| v.as_array()).expect("nodes array");
6728        let edges = body.get("edges").and_then(|v| v.as_array()).expect("edges array");
6729        assert_eq!(nodes.len(), 1, "{body}");
6730        assert_eq!(nodes[0]["id"], "cl:cl-a");
6731        assert_eq!(nodes[0]["kind"], "cluster");
6732        assert_eq!(edges.len(), 1);
6733        assert_eq!(edges[0]["source"], node_id);
6734        assert_eq!(edges[0]["target"], "cl:cl-a");
6735        assert_eq!(edges[0]["kind"], "cluster_member");
6736        h.shutdown(&runtime);
6737    }
6738
6739    #[test]
6740    fn expand_cluster_member_from_cluster_returns_episodes() {
6741        let runtime = rt();
6742        let h = Harness::new(&runtime);
6743        {
6744            let conn = h.open_db();
6745            seed_cluster_row(&conn, "cl-multi", 500);
6746            for i in 0..5 {
6747                let mid = format!("2222{i}222-2222-7000-8000-000000000001");
6748                seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
6749                seed_cluster_member(&conn, "cl-multi", &mid);
6750            }
6751        }
6752        let (status, body) = runtime.block_on(call(
6753            h.router.clone(),
6754            "GET",
6755            &graph_uri_with_limit("cl:cl-multi", "cluster_member", 3),
6756            None,
6757        ));
6758        assert_eq!(status, StatusCode::OK, "body: {body}");
6759        let nodes = body["nodes"].as_array().unwrap();
6760        let edges = body["edges"].as_array().unwrap();
6761        assert_eq!(nodes.len(), 3, "limit honored: {body}");
6762        assert_eq!(edges.len(), 3);
6763        for n in nodes {
6764            assert_eq!(n["kind"], "episode");
6765        }
6766        h.shutdown(&runtime);
6767    }
6768
6769    #[test]
6770    fn expand_document_chunk_from_document_returns_chunks() {
6771        let runtime = rt();
6772        let h = Harness::new(&runtime);
6773        let doc_id = "33333333-3333-7000-8000-000000000001";
6774        {
6775            let conn = h.open_db();
6776            seed_document_row(&conn, doc_id, "doc A");
6777            // Insert chunks in shuffled order so the ORDER BY chunk_index
6778            // is load-bearing.
6779            seed_chunk_row(&conn, "c2", doc_id, 2, "chunk 2 text");
6780            seed_chunk_row(&conn, "c0", doc_id, 0, "chunk 0 text");
6781            seed_chunk_row(&conn, "c1", doc_id, 1, "chunk 1 text");
6782            seed_chunk_row(&conn, "c3", doc_id, 3, "chunk 3 text");
6783        }
6784        let node_id = format!("doc:{doc_id}");
6785        let (status, body) = runtime.block_on(call(
6786            h.router.clone(),
6787            "GET",
6788            &graph_uri(&node_id, "document_chunk"),
6789            None,
6790        ));
6791        assert_eq!(status, StatusCode::OK, "body: {body}");
6792        let nodes = body["nodes"].as_array().unwrap();
6793        let edges = body["edges"].as_array().unwrap();
6794        assert_eq!(nodes.len(), 4);
6795        assert_eq!(edges.len(), 4);
6796        // Verify in-order chunk_index emission.
6797        assert_eq!(nodes[0]["id"], "chunk:c0");
6798        assert_eq!(nodes[1]["id"], "chunk:c1");
6799        assert_eq!(nodes[2]["id"], "chunk:c2");
6800        assert_eq!(nodes[3]["id"], "chunk:c3");
6801        for e in edges {
6802            assert_eq!(e["kind"], "document_chunk");
6803        }
6804        h.shutdown(&runtime);
6805    }
6806
6807    #[test]
6808    fn expand_document_chunk_from_chunk_returns_parent_document() {
6809        let runtime = rt();
6810        let h = Harness::new(&runtime);
6811        let doc_id = "44444444-4444-7000-8000-000000000001";
6812        {
6813            let conn = h.open_db();
6814            seed_document_row(&conn, doc_id, "parent doc");
6815            seed_chunk_row(&conn, "c-orphan", doc_id, 0, "chunk content");
6816        }
6817        let (status, body) = runtime.block_on(call(
6818            h.router.clone(),
6819            "GET",
6820            &graph_uri("chunk:c-orphan", "document_chunk"),
6821            None,
6822        ));
6823        assert_eq!(status, StatusCode::OK, "body: {body}");
6824        let nodes = body["nodes"].as_array().unwrap();
6825        let edges = body["edges"].as_array().unwrap();
6826        assert_eq!(nodes.len(), 1);
6827        assert_eq!(edges.len(), 1);
6828        assert_eq!(nodes[0]["id"], format!("doc:{doc_id}"));
6829        assert_eq!(edges[0]["source"], "chunk:c-orphan");
6830        assert_eq!(edges[0]["target"], format!("doc:{doc_id}"));
6831        h.shutdown(&runtime);
6832    }
6833
6834    #[test]
6835    fn expand_triple_from_episode_returns_entities() {
6836        let runtime = rt();
6837        let h = Harness::new(&runtime);
6838        let memory_id = "55555555-5555-7000-8000-000000000001";
6839        let rowid;
6840        {
6841            let conn = h.open_db();
6842            rowid = seed_episode(&conn, memory_id, 100, "alice works at anthropic");
6843            // Two distinct triples → 4 entity endpoints (Alice, Anthropic, Bob, NYC).
6844            seed_triple_row(&conn, "t1", "Alice", "works_at", "Anthropic", Some(rowid));
6845            seed_triple_row(&conn, "t2", "Bob", "lives_in", "NYC", Some(rowid));
6846        }
6847        let node_id = format!("ep:{memory_id}");
6848        let (status, body) = runtime.block_on(call(
6849            h.router.clone(),
6850            "GET",
6851            &graph_uri(&node_id, "triple"),
6852            None,
6853        ));
6854        assert_eq!(status, StatusCode::OK, "body: {body}");
6855        let nodes = body["nodes"].as_array().unwrap();
6856        let edges = body["edges"].as_array().unwrap();
6857        assert_eq!(nodes.len(), 4, "expected 4 unique entity nodes: {body}");
6858        assert_eq!(edges.len(), 2);
6859        let ids: std::collections::HashSet<String> = nodes
6860            .iter()
6861            .map(|n| n["id"].as_str().unwrap().to_string())
6862            .collect();
6863        for expected in ["ent:Alice", "ent:Anthropic", "ent:Bob", "ent:NYC"] {
6864            assert!(ids.contains(expected), "missing {expected} in {body}");
6865        }
6866        for e in edges {
6867            assert_eq!(e["kind"], "triple");
6868            assert!(e["predicate"].is_string(), "predicate set: {body}");
6869        }
6870        h.shutdown(&runtime);
6871    }
6872
6873    #[test]
6874    fn expand_triple_from_entity_returns_episodes() {
6875        let runtime = rt();
6876        let h = Harness::new(&runtime);
6877        {
6878            let conn = h.open_db();
6879            let r1 = seed_episode(
6880                &conn,
6881                "66666666-6666-7000-8000-000000000001",
6882                100,
6883                "alice ep one",
6884            );
6885            let r2 = seed_episode(
6886                &conn,
6887                "66666666-6666-7000-8000-000000000002",
6888                200,
6889                "alice ep two",
6890            );
6891            let r3 = seed_episode(
6892                &conn,
6893                "66666666-6666-7000-8000-000000000003",
6894                300,
6895                "alice ep three",
6896            );
6897            // 3 triples all mentioning Alice on one side or another.
6898            seed_triple_row(&conn, "t1", "Alice", "p", "Bob", Some(r1));
6899            seed_triple_row(&conn, "t2", "Carol", "p", "Alice", Some(r2));
6900            seed_triple_row(&conn, "t3", "Alice", "q", "Dave", Some(r3));
6901            // One triple with no source — must be skipped by the IS NOT NULL filter.
6902            seed_triple_row(&conn, "t-orphan", "Alice", "p", "Eve", None);
6903        }
6904        let (status, body) = runtime.block_on(call(
6905            h.router.clone(),
6906            "GET",
6907            &graph_uri("ent:Alice", "triple"),
6908            None,
6909        ));
6910        assert_eq!(status, StatusCode::OK, "body: {body}");
6911        let nodes = body["nodes"].as_array().unwrap();
6912        let edges = body["edges"].as_array().unwrap();
6913        assert_eq!(nodes.len(), 3, "expected 3 episodes: {body}");
6914        assert_eq!(edges.len(), 3);
6915        for n in nodes {
6916            assert_eq!(n["kind"], "episode");
6917        }
6918        for e in edges {
6919            assert_eq!(e["source"], "ent:Alice");
6920            assert_eq!(e["kind"], "triple");
6921        }
6922        h.shutdown(&runtime);
6923    }
6924
6925    #[test]
6926    fn expand_semantic_from_episode_returns_similar() {
6927        let runtime = rt();
6928        let h = Harness::new(&runtime);
6929        // Seed three episodes via the writer-actor so they get embedded
6930        // + inserted into HNSW. StubEmbedder is deterministic: identical
6931        // content → identical vector → cos_distance = 0. So we use
6932        // distinct strings, then expand from one of them and assert at
6933        // least one similar peer comes back.
6934        runtime.block_on(async {
6935            let mid1 = post_remember(h.router.clone(), "alpha alpha alpha").await;
6936            let _mid2 = post_remember(h.router.clone(), "beta beta beta").await;
6937            let _mid3 = post_remember(h.router.clone(), "gamma gamma gamma").await;
6938            // Expand from mid1.
6939            let (status, body) = call(
6940                h.router.clone(),
6941                "GET",
6942                &graph_uri_with_limit(&format!("ep:{mid1}"), "semantic", 5),
6943                None,
6944            )
6945            .await;
6946            assert_eq!(status, StatusCode::OK, "body: {body}");
6947            let nodes = body["nodes"].as_array().unwrap();
6948            let edges = body["edges"].as_array().unwrap();
6949            // Must NOT include the source.
6950            for n in nodes {
6951                assert_ne!(
6952                    n["id"].as_str().unwrap(),
6953                    format!("ep:{mid1}"),
6954                    "self must be excluded: {body}"
6955                );
6956            }
6957            // Edges must be tagged semantic with a numeric weight.
6958            for e in edges {
6959                assert_eq!(e["kind"], "semantic");
6960                assert!(e["weight"].is_number(), "weight set: {body}");
6961            }
6962        });
6963        h.shutdown(&runtime);
6964    }
6965
6966    /// Helper: POST /memory and return the new memory_id.
6967    async fn post_remember(router: axum::Router, content: &str) -> String {
6968        let (status, body) = call(
6969            router,
6970            "POST",
6971            "/memory",
6972            Some(json!({ "content": content })),
6973        )
6974        .await;
6975        assert_eq!(status, StatusCode::OK, "post failed: {body}");
6976        body["memory_id"].as_str().unwrap().to_string()
6977    }
6978
6979    #[test]
6980    fn expand_400_on_invalid_kind() {
6981        let runtime = rt();
6982        let h = Harness::new(&runtime);
6983        let (status, _body) = runtime.block_on(call(
6984            h.router.clone(),
6985            "GET",
6986            "/v1/graph/expand?node_id=ep:any&kind=banana",
6987            None,
6988        ));
6989        // axum's Query extractor rejects unknown enum value with 400/422.
6990        assert!(
6991            status == StatusCode::BAD_REQUEST || status == StatusCode::UNPROCESSABLE_ENTITY,
6992            "expected 400/422 for bad kind, got {status}"
6993        );
6994        h.shutdown(&runtime);
6995    }
6996
6997    #[test]
6998    fn expand_400_on_invalid_node_for_kind() {
6999        let runtime = rt();
7000        let h = Harness::new(&runtime);
7001        // kind=semantic from a cluster source → 400.
7002        let (status, body) = runtime.block_on(call(
7003            h.router.clone(),
7004            "GET",
7005            &graph_uri("cl:doesnt-matter", "semantic"),
7006            None,
7007        ));
7008        assert_eq!(status, StatusCode::BAD_REQUEST);
7009        assert!(
7010            body["error"]
7011                .as_str()
7012                .is_some_and(|s| s.contains("semantic only valid for episode")),
7013            "got: {body}"
7014        );
7015        h.shutdown(&runtime);
7016    }
7017
7018    #[test]
7019    fn expand_404_on_missing_node_id() {
7020        let runtime = rt();
7021        let h = Harness::new(&runtime);
7022        let (status, body) = runtime.block_on(call(
7023            h.router.clone(),
7024            "GET",
7025            &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7026            None,
7027        ));
7028        assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
7029        h.shutdown(&runtime);
7030    }
7031
7032    #[test]
7033    fn expand_limit_clamped_at_100() {
7034        let runtime = rt();
7035        let h = Harness::new(&runtime);
7036        // Seed > 100 cluster members so we can see the clamp in action.
7037        {
7038            let conn = h.open_db();
7039            seed_cluster_row(&conn, "cl-huge", 1_000);
7040            for i in 0..150 {
7041                let mid = format!("77777777-7777-7000-8000-{:012}", i);
7042                seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
7043                seed_cluster_member(&conn, "cl-huge", &mid);
7044            }
7045        }
7046        let (status, body) = runtime.block_on(call(
7047            h.router.clone(),
7048            "GET",
7049            &graph_uri_with_limit("cl:cl-huge", "cluster_member", 999),
7050            None,
7051        ));
7052        assert_eq!(status, StatusCode::OK, "body: {body}");
7053        let nodes = body["nodes"].as_array().unwrap();
7054        assert_eq!(
7055            nodes.len(),
7056            100,
7057            "limit must be silently clamped to 100, got {}",
7058            nodes.len()
7059        );
7060        h.shutdown(&runtime);
7061    }
7062
7063    #[test]
7064    fn expand_bad_node_id_prefix_returns_400() {
7065        let runtime = rt();
7066        let h = Harness::new(&runtime);
7067        let (status, body) = runtime.block_on(call(
7068            h.router.clone(),
7069            "GET",
7070            "/v1/graph/expand?node_id=garbage&kind=cluster_member",
7071            None,
7072        ));
7073        assert_eq!(status, StatusCode::BAD_REQUEST);
7074        assert!(
7075            body["error"]
7076                .as_str()
7077                .is_some_and(|s| s.contains("node_id must be")),
7078            "got: {body}"
7079        );
7080        h.shutdown(&runtime);
7081    }
7082
7083    #[test]
7084    fn expand_respects_tenant_scoping_via_unknown_tenant_header() {
7085        // Routing via X-Solo-Tenant: a header pointing to an unknown
7086        // tenant must 404 before the handler even runs — the
7087        // TenantExtractor is the gatekeeper, so node ids can't be
7088        // resolved against the wrong tenant's DB.
7089        let runtime = rt();
7090        let h = Harness::new(&runtime);
7091        // Seed a real episode in the default tenant so we know it
7092        // exists there. If tenant scoping leaked, this lookup would 200
7093        // even with the wrong tenant header.
7094        let memory_id = "88888888-8888-7000-8000-000000000001";
7095        {
7096            let conn = h.open_db();
7097            seed_episode(&conn, memory_id, 100, "scoped");
7098            seed_cluster_row(&conn, "cl-scoped", 200);
7099            seed_cluster_member(&conn, "cl-scoped", memory_id);
7100        }
7101        let node_id = format!("ep:{memory_id}");
7102        let r = h.router.clone();
7103        let (status, _body) = runtime.block_on(async {
7104            let req = Request::builder()
7105                .method("GET")
7106                .uri(graph_uri(&node_id, "cluster_member"))
7107                .header("x-solo-tenant", "never-registered-tenant")
7108                .body(Body::empty())
7109                .unwrap();
7110            let resp = r.oneshot(req).await.expect("oneshot");
7111            let s = resp.status();
7112            let _b = resp.into_body().collect().await.unwrap().to_bytes();
7113            (s, _b)
7114        });
7115        // Unknown tenant id → 404 from the registry. Confirms cross-tenant
7116        // lookups can't smuggle through this endpoint.
7117        assert_eq!(status, StatusCode::NOT_FOUND);
7118        h.shutdown(&runtime);
7119    }
7120
7121    #[test]
7122    fn expand_respects_auth_when_enabled() {
7123        let runtime = rt();
7124        let h = Harness::new_with_auth(&runtime, Some("graph-secret".into()));
7125        // No Authorization header → 401.
7126        let (status, _) = runtime.block_on(call(
7127            h.router.clone(),
7128            "GET",
7129            &graph_uri("ep:any", "cluster_member"),
7130            None,
7131        ));
7132        assert_eq!(status, StatusCode::UNAUTHORIZED);
7133        // Right token → handler runs (404 for unknown node, NOT 401).
7134        let (status, _) = runtime.block_on(call_with_auth(
7135            h.router.clone(),
7136            "GET",
7137            &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7138            None,
7139            Some("Bearer graph-secret"),
7140        ));
7141        assert_eq!(status, StatusCode::NOT_FOUND);
7142        h.shutdown(&runtime);
7143    }
7144
7145    #[test]
7146    fn expand_works_when_auth_none() {
7147        let runtime = rt();
7148        let h = Harness::new(&runtime);
7149        // Unauthenticated request hits the handler; 404 for unknown node
7150        // proves the auth-none path doesn't reject the request.
7151        let (status, _) = runtime.block_on(call(
7152            h.router.clone(),
7153            "GET",
7154            &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7155            None,
7156        ));
7157        assert_eq!(status, StatusCode::NOT_FOUND);
7158        h.shutdown(&runtime);
7159    }
7160
7161    // ---------------------------------------------------------------------
7162    // v0.10.0: GET /v1/graph/nodes + GET /v1/graph/edges
7163    //
7164    // Paginated catalog reads. Both endpoints share auth + tenant +
7165    // cursor scaffolding from /v1/graph/expand, so tests focus on the
7166    // new surface: filter parsing, entity synthesis cap, cursor round-
7167    // trip, edge-type defaults (semantic excluded), and the semantic
7168    // 400 redirect to /v1/graph/neighbors.
7169    // ---------------------------------------------------------------------
7170
7171    /// Lower-level helper that captures response headers in addition to
7172    /// status + JSON body. Used by the entity-cap header test.
7173    async fn call_with_headers(
7174        router: axum::Router,
7175        method: &str,
7176        uri: &str,
7177    ) -> (StatusCode, axum::http::HeaderMap, Value) {
7178        let req = Request::builder()
7179            .method(method)
7180            .uri(uri)
7181            .header("content-length", "0")
7182            .body(Body::empty())
7183            .unwrap();
7184        let resp = router.oneshot(req).await.expect("oneshot");
7185        let status = resp.status();
7186        let headers = resp.headers().clone();
7187        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
7188        let v: Value = if body_bytes.is_empty() {
7189            Value::Null
7190        } else {
7191            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
7192        };
7193        (status, headers, v)
7194    }
7195
7196    #[test]
7197    fn nodes_returns_all_kinds_when_no_filter() {
7198        let runtime = rt();
7199        let h = Harness::new(&runtime);
7200        {
7201            let conn = h.open_db();
7202            let rowid = seed_episode(
7203                &conn,
7204                "aaaaaaaa-0000-7000-8000-000000000001",
7205                100,
7206                "episode one",
7207            );
7208            seed_document_row(&conn, "doc-1", "doc one");
7209            seed_chunk_row(&conn, "chunk-1", "doc-1", 0, "chunk one body");
7210            seed_cluster_row(&conn, "cl-one", 200);
7211            seed_triple_row(
7212                &conn,
7213                "t-one",
7214                "Alice",
7215                "knows",
7216                "Bob",
7217                Some(rowid),
7218            );
7219        }
7220        let (status, body) = runtime.block_on(call(
7221            h.router.clone(),
7222            "GET",
7223            "/v1/graph/nodes",
7224            None,
7225        ));
7226        assert_eq!(status, StatusCode::OK, "body: {body}");
7227        let nodes = body["nodes"].as_array().unwrap();
7228        let kinds: std::collections::HashSet<&str> = nodes
7229            .iter()
7230            .map(|n| n["kind"].as_str().unwrap())
7231            .collect();
7232        for expected in ["episode", "document", "chunk", "cluster", "entity"] {
7233            assert!(
7234                kinds.contains(expected),
7235                "expected {expected} kind in response: {body}"
7236            );
7237        }
7238        h.shutdown(&runtime);
7239    }
7240
7241    #[test]
7242    fn nodes_filter_by_single_kind() {
7243        let runtime = rt();
7244        let h = Harness::new(&runtime);
7245        {
7246            let conn = h.open_db();
7247            seed_episode(&conn, "bbbbbbbb-0000-7000-8000-000000000001", 100, "ep");
7248            seed_document_row(&conn, "doc-only", "d");
7249            seed_cluster_row(&conn, "cl-only", 300);
7250        }
7251        let (status, body) = runtime.block_on(call(
7252            h.router.clone(),
7253            "GET",
7254            "/v1/graph/nodes?kind=episode",
7255            None,
7256        ));
7257        assert_eq!(status, StatusCode::OK, "body: {body}");
7258        let nodes = body["nodes"].as_array().unwrap();
7259        assert!(!nodes.is_empty(), "{body}");
7260        for n in nodes {
7261            assert_eq!(n["kind"], "episode", "kind filter must be exclusive: {body}");
7262        }
7263        h.shutdown(&runtime);
7264    }
7265
7266    #[test]
7267    fn nodes_filter_by_multiple_kinds() {
7268        let runtime = rt();
7269        let h = Harness::new(&runtime);
7270        {
7271            let conn = h.open_db();
7272            seed_episode(&conn, "cccccccc-0000-7000-8000-000000000001", 100, "ep");
7273            seed_document_row(&conn, "doc-multi", "d");
7274            seed_cluster_row(&conn, "cl-multi", 300);
7275        }
7276        let (status, body) = runtime.block_on(call(
7277            h.router.clone(),
7278            "GET",
7279            "/v1/graph/nodes?kind=episode,document",
7280            None,
7281        ));
7282        assert_eq!(status, StatusCode::OK, "body: {body}");
7283        let nodes = body["nodes"].as_array().unwrap();
7284        let kinds: std::collections::HashSet<&str> = nodes
7285            .iter()
7286            .map(|n| n["kind"].as_str().unwrap())
7287            .collect();
7288        assert!(kinds.contains("episode"), "{body}");
7289        assert!(kinds.contains("document"), "{body}");
7290        assert!(
7291            !kinds.contains("cluster"),
7292            "cluster must be filtered out: {body}"
7293        );
7294        h.shutdown(&runtime);
7295    }
7296
7297    #[test]
7298    fn nodes_entity_synthesis_caps_at_200() {
7299        let runtime = rt();
7300        let h = Harness::new(&runtime);
7301        {
7302            let conn = h.open_db();
7303            // Seed one episode + 250 distinct triple object values so the
7304            // entity rollup surfaces >200 entities. ref_count is 1 for
7305            // each; pick subject = "Alice" for all so the entity count
7306            // collapses on subject (1 "Alice") + 250 distinct objects.
7307            let rowid = seed_episode(
7308                &conn,
7309                "dddddddd-0000-7000-8000-000000000001",
7310                100,
7311                "ep",
7312            );
7313            for i in 0..250 {
7314                let triple_id = format!("t-cap-{i:03}");
7315                let obj = format!("Entity{i:03}");
7316                seed_triple_row(&conn, &triple_id, "Alice", "knows", &obj, Some(rowid));
7317            }
7318        }
7319        let (status, headers, body) = runtime.block_on(call_with_headers(
7320            h.router.clone(),
7321            "GET",
7322            "/v1/graph/nodes?kind=entity&limit=500",
7323        ));
7324        assert_eq!(status, StatusCode::OK, "body: {body}");
7325        let nodes = body["nodes"].as_array().unwrap();
7326        assert_eq!(
7327            nodes.len(),
7328            200,
7329            "entity cap must be enforced at 200, got {}",
7330            nodes.len()
7331        );
7332        assert_eq!(
7333            headers
7334                .get("x-solo-entity-cap-reached")
7335                .and_then(|v| v.to_str().ok()),
7336            Some("true"),
7337            "cap-reached header missing: headers={headers:?}"
7338        );
7339        for n in nodes {
7340            assert_eq!(n["kind"], "entity");
7341        }
7342        h.shutdown(&runtime);
7343    }
7344
7345    #[test]
7346    fn nodes_since_until_filter_works() {
7347        let runtime = rt();
7348        let h = Harness::new(&runtime);
7349        {
7350            let conn = h.open_db();
7351            seed_episode(
7352                &conn,
7353                "eeeeeeee-0000-7000-8000-000000000001",
7354                100,
7355                "early",
7356            );
7357            seed_episode(
7358                &conn,
7359                "eeeeeeee-0000-7000-8000-000000000002",
7360                500,
7361                "middle",
7362            );
7363            seed_episode(
7364                &conn,
7365                "eeeeeeee-0000-7000-8000-000000000003",
7366                1000,
7367                "late",
7368            );
7369        }
7370        let (status, body) = runtime.block_on(call(
7371            h.router.clone(),
7372            "GET",
7373            "/v1/graph/nodes?kind=episode&since_ms=400&until_ms=600",
7374            None,
7375        ));
7376        assert_eq!(status, StatusCode::OK, "body: {body}");
7377        let nodes = body["nodes"].as_array().unwrap();
7378        assert_eq!(nodes.len(), 1, "{body}");
7379        assert_eq!(
7380            nodes[0]["id"],
7381            "ep:eeeeeeee-0000-7000-8000-000000000002"
7382        );
7383        h.shutdown(&runtime);
7384    }
7385
7386    #[test]
7387    fn nodes_pagination_round_trip() {
7388        let runtime = rt();
7389        let h = Harness::new(&runtime);
7390        {
7391            let conn = h.open_db();
7392            for i in 0..150 {
7393                let mid = format!("f0000000-0000-7000-8000-{i:012}");
7394                // ts_ms scales with i so the sort order is deterministic;
7395                // newest (highest i) appears first.
7396                seed_episode(&conn, &mid, 1_000 + i as i64, "page");
7397            }
7398        }
7399        let limit = 50u32;
7400        let mut seen: std::collections::HashSet<String> = Default::default();
7401        let mut next_cursor: Option<String> = None;
7402        for page_idx in 0..4 {
7403            let cursor_param = next_cursor
7404                .as_deref()
7405                .map(|c| format!("&cursor={c}"))
7406                .unwrap_or_default();
7407            let uri = format!(
7408                "/v1/graph/nodes?kind=episode&limit={limit}{cursor_param}"
7409            );
7410            let (status, body) =
7411                runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7412            assert_eq!(status, StatusCode::OK, "page {page_idx}: {body}");
7413            let nodes = body["nodes"].as_array().unwrap();
7414            assert!(
7415                nodes.len() <= limit as usize,
7416                "page {page_idx} over-fetched: {body}"
7417            );
7418            for n in nodes {
7419                let id = n["id"].as_str().unwrap().to_string();
7420                assert!(seen.insert(id.clone()), "duplicate id across pages: {id}");
7421            }
7422            next_cursor = body
7423                .get("next_cursor")
7424                .and_then(|v| v.as_str())
7425                .map(|s| s.to_string());
7426            if next_cursor.is_none() {
7427                break;
7428            }
7429        }
7430        assert_eq!(
7431            seen.len(),
7432            150,
7433            "expected 150 distinct ids across pages, got {}",
7434            seen.len()
7435        );
7436        assert!(
7437            next_cursor.is_none(),
7438            "cursor should be null after last page; got {next_cursor:?}"
7439        );
7440        h.shutdown(&runtime);
7441    }
7442
7443    #[test]
7444    fn nodes_respects_tenant_scoping() {
7445        let runtime = rt();
7446        let h = Harness::new(&runtime);
7447        {
7448            let conn = h.open_db();
7449            seed_episode(
7450                &conn,
7451                "11110000-0000-7000-8000-000000000001",
7452                100,
7453                "tenant scope",
7454            );
7455        }
7456        // Request against a never-registered tenant header → 404 from
7457        // the tenant extractor before the handler runs.
7458        let r = h.router.clone();
7459        let (status, _body) = runtime.block_on(async {
7460            let req = Request::builder()
7461                .method("GET")
7462                .uri("/v1/graph/nodes")
7463                .header("x-solo-tenant", "never-registered-tenant")
7464                .body(Body::empty())
7465                .unwrap();
7466            let resp = r.oneshot(req).await.expect("oneshot");
7467            let s = resp.status();
7468            let _b = resp.into_body().collect().await.unwrap().to_bytes();
7469            (s, _b)
7470        });
7471        assert_eq!(status, StatusCode::NOT_FOUND);
7472        h.shutdown(&runtime);
7473    }
7474
7475    #[test]
7476    fn nodes_respects_auth_when_enabled() {
7477        let runtime = rt();
7478        let h = Harness::new_with_auth(&runtime, Some("nodes-secret".into()));
7479        let (status, _) = runtime.block_on(call(
7480            h.router.clone(),
7481            "GET",
7482            "/v1/graph/nodes",
7483            None,
7484        ));
7485        assert_eq!(
7486            status,
7487            StatusCode::UNAUTHORIZED,
7488            "must reject unauthenticated request"
7489        );
7490        let (status, _) = runtime.block_on(call_with_auth(
7491            h.router.clone(),
7492            "GET",
7493            "/v1/graph/nodes",
7494            None,
7495            Some("Bearer nodes-secret"),
7496        ));
7497        assert_eq!(status, StatusCode::OK, "must pass through with bearer");
7498        h.shutdown(&runtime);
7499    }
7500
7501    #[test]
7502    fn nodes_works_with_auth_none() {
7503        let runtime = rt();
7504        let h = Harness::new(&runtime);
7505        let (status, body) = runtime.block_on(call(
7506            h.router.clone(),
7507            "GET",
7508            "/v1/graph/nodes",
7509            None,
7510        ));
7511        assert_eq!(status, StatusCode::OK, "{body}");
7512        assert!(body.get("nodes").is_some());
7513        h.shutdown(&runtime);
7514    }
7515
7516    // --- /v1/graph/edges ---
7517
7518    #[test]
7519    fn edges_returns_all_default_kinds() {
7520        let runtime = rt();
7521        let h = Harness::new(&runtime);
7522        {
7523            let conn = h.open_db();
7524            let rowid = seed_episode(
7525                &conn,
7526                "22220000-0000-7000-8000-000000000001",
7527                100,
7528                "ep src",
7529            );
7530            seed_triple_row(&conn, "t-def", "Alice", "knows", "Bob", Some(rowid));
7531            seed_document_row(&conn, "doc-e", "doc");
7532            seed_chunk_row(&conn, "c-e", "doc-e", 0, "chunk");
7533            seed_cluster_row(&conn, "cl-e", 200);
7534            seed_cluster_member(
7535                &conn,
7536                "cl-e",
7537                "22220000-0000-7000-8000-000000000001",
7538            );
7539        }
7540        let (status, body) = runtime.block_on(call(
7541            h.router.clone(),
7542            "GET",
7543            "/v1/graph/edges",
7544            None,
7545        ));
7546        assert_eq!(status, StatusCode::OK, "body: {body}");
7547        let edges = body["edges"].as_array().unwrap();
7548        let kinds: std::collections::HashSet<&str> = edges
7549            .iter()
7550            .map(|e| e["kind"].as_str().unwrap())
7551            .collect();
7552        assert!(kinds.contains("triple"), "{body}");
7553        assert!(kinds.contains("document_chunk"), "{body}");
7554        assert!(kinds.contains("cluster_member"), "{body}");
7555        assert!(
7556            !kinds.contains("semantic"),
7557            "semantic is NOT in default response: {body}"
7558        );
7559        h.shutdown(&runtime);
7560    }
7561
7562    #[test]
7563    fn edges_filter_by_node_id_finds_incident_edges() {
7564        let runtime = rt();
7565        let h = Harness::new(&runtime);
7566        let memory_id = "33330000-0000-7000-8000-000000000001";
7567        {
7568            let conn = h.open_db();
7569            let rowid = seed_episode(&conn, memory_id, 100, "ep multi-triple");
7570            seed_triple_row(&conn, "t-a", "Alice", "p", "Bob", Some(rowid));
7571            seed_triple_row(&conn, "t-b", "Alice", "p", "Carol", Some(rowid));
7572            seed_triple_row(&conn, "t-c", "Alice", "p", "Dave", Some(rowid));
7573            // Decoy episode with its own triple — must NOT come back.
7574            let decoy_rowid = seed_episode(
7575                &conn,
7576                "33330000-0000-7000-8000-000000000999",
7577                200,
7578                "decoy",
7579            );
7580            seed_triple_row(
7581                &conn,
7582                "t-decoy",
7583                "Alice",
7584                "p",
7585                "Eve",
7586                Some(decoy_rowid),
7587            );
7588        }
7589        let uri = format!(
7590            "/v1/graph/edges?type=triple&node_id={}",
7591            percent_encode_node_id(&format!("ep:{memory_id}"))
7592        );
7593        let (status, body) =
7594            runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7595        assert_eq!(status, StatusCode::OK, "body: {body}");
7596        let edges = body["edges"].as_array().unwrap();
7597        assert_eq!(edges.len(), 3, "expected 3 incident edges: {body}");
7598        for e in edges {
7599            assert_eq!(e["source"], format!("ep:{memory_id}"));
7600            assert_eq!(e["kind"], "triple");
7601        }
7602        h.shutdown(&runtime);
7603    }
7604
7605    #[test]
7606    fn edges_filter_by_type_works() {
7607        let runtime = rt();
7608        let h = Harness::new(&runtime);
7609        {
7610            let conn = h.open_db();
7611            let rowid = seed_episode(
7612                &conn,
7613                "44440000-0000-7000-8000-000000000001",
7614                100,
7615                "ep",
7616            );
7617            seed_triple_row(&conn, "t-only", "Alice", "p", "Bob", Some(rowid));
7618            seed_document_row(&conn, "doc-skip", "doc");
7619            seed_chunk_row(&conn, "c-skip", "doc-skip", 0, "chunk");
7620        }
7621        let (status, body) = runtime.block_on(call(
7622            h.router.clone(),
7623            "GET",
7624            "/v1/graph/edges?type=triple",
7625            None,
7626        ));
7627        assert_eq!(status, StatusCode::OK, "{body}");
7628        let edges = body["edges"].as_array().unwrap();
7629        assert!(!edges.is_empty(), "{body}");
7630        for e in edges {
7631            assert_eq!(e["kind"], "triple", "{body}");
7632        }
7633        h.shutdown(&runtime);
7634    }
7635
7636    #[test]
7637    fn edges_rejects_semantic_type_with_400() {
7638        let runtime = rt();
7639        let h = Harness::new(&runtime);
7640        let (status, body) = runtime.block_on(call(
7641            h.router.clone(),
7642            "GET",
7643            "/v1/graph/edges?type=semantic",
7644            None,
7645        ));
7646        assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
7647        let err = body["error"].as_str().unwrap_or_default();
7648        assert!(
7649            err.contains("/v1/graph/neighbors"),
7650            "error must point to /v1/graph/neighbors: {body}"
7651        );
7652        h.shutdown(&runtime);
7653    }
7654
7655    #[test]
7656    fn edges_pagination_round_trip() {
7657        let runtime = rt();
7658        let h = Harness::new(&runtime);
7659        {
7660            let conn = h.open_db();
7661            let rowid = seed_episode(
7662                &conn,
7663                "55550000-0000-7000-8000-000000000001",
7664                100,
7665                "ep big",
7666            );
7667            // 60 triples → 60 triple edges. limit=25 → 3 pages.
7668            for i in 0..60 {
7669                let tid = format!("t-page-{i:03}");
7670                let obj = format!("Obj{i:03}");
7671                seed_triple_row(&conn, &tid, "Alice", "p", &obj, Some(rowid));
7672            }
7673        }
7674        let limit = 25u32;
7675        let mut seen: std::collections::HashSet<String> = Default::default();
7676        let mut next_cursor: Option<String> = None;
7677        for page_idx in 0..5 {
7678            let cursor_param = next_cursor
7679                .as_deref()
7680                .map(|c| format!("&cursor={c}"))
7681                .unwrap_or_default();
7682            let uri = format!(
7683                "/v1/graph/edges?type=triple&limit={limit}{cursor_param}"
7684            );
7685            let (status, body) =
7686                runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7687            assert_eq!(status, StatusCode::OK, "page {page_idx}: {body}");
7688            let edges = body["edges"].as_array().unwrap();
7689            for e in edges {
7690                let id = e["id"].as_str().unwrap().to_string();
7691                assert!(seen.insert(id.clone()), "duplicate edge id: {id}");
7692            }
7693            next_cursor = body
7694                .get("next_cursor")
7695                .and_then(|v| v.as_str())
7696                .map(|s| s.to_string());
7697            if next_cursor.is_none() {
7698                break;
7699            }
7700        }
7701        assert_eq!(
7702            seen.len(),
7703            60,
7704            "expected 60 distinct edges, got {}",
7705            seen.len()
7706        );
7707        assert!(next_cursor.is_none(), "expected exhausted cursor");
7708        h.shutdown(&runtime);
7709    }
7710
7711    #[test]
7712    fn edges_respects_tenant_scoping() {
7713        let runtime = rt();
7714        let h = Harness::new(&runtime);
7715        {
7716            let conn = h.open_db();
7717            let rowid = seed_episode(
7718                &conn,
7719                "66660000-0000-7000-8000-000000000001",
7720                100,
7721                "ep",
7722            );
7723            seed_triple_row(&conn, "t-tenant", "Alice", "p", "Bob", Some(rowid));
7724        }
7725        let r = h.router.clone();
7726        let (status, _) = runtime.block_on(async {
7727            let req = Request::builder()
7728                .method("GET")
7729                .uri("/v1/graph/edges")
7730                .header("x-solo-tenant", "never-registered-tenant")
7731                .body(Body::empty())
7732                .unwrap();
7733            let resp = r.oneshot(req).await.expect("oneshot");
7734            let s = resp.status();
7735            let _b = resp.into_body().collect().await.unwrap().to_bytes();
7736            (s, _b)
7737        });
7738        assert_eq!(status, StatusCode::NOT_FOUND);
7739        h.shutdown(&runtime);
7740    }
7741
7742    #[test]
7743    fn edges_respects_auth_when_enabled() {
7744        let runtime = rt();
7745        let h = Harness::new_with_auth(&runtime, Some("edges-secret".into()));
7746        let (status, _) = runtime.block_on(call(
7747            h.router.clone(),
7748            "GET",
7749            "/v1/graph/edges",
7750            None,
7751        ));
7752        assert_eq!(status, StatusCode::UNAUTHORIZED);
7753        let (status, _) = runtime.block_on(call_with_auth(
7754            h.router.clone(),
7755            "GET",
7756            "/v1/graph/edges",
7757            None,
7758            Some("Bearer edges-secret"),
7759        ));
7760        assert_eq!(status, StatusCode::OK);
7761        h.shutdown(&runtime);
7762    }
7763
7764    // ---------------------------------------------------------------------
7765    // v0.10.0: GET /v1/graph/inspect/{id}
7766    //
7767    // Kind-discriminated full-record drill. Shares auth + tenant + node-id
7768    // prefix scaffolding with /v1/graph/expand and /v1/graph/{nodes,edges},
7769    // so tests focus on the new surface: per-kind full_text source +
7770    // triples_in/out shape + entity zero-triple 404 semantics + the
7771    // standard 400/404/auth/tenant cases.
7772    // ---------------------------------------------------------------------
7773
7774    fn inspect_uri(node_id: &str) -> String {
7775        // Path parameter must be percent-encoded (`:` is `%3A` after
7776        // the URI parser splits segments). axum's Path<String>
7777        // extractor percent-decodes automatically.
7778        format!("/v1/graph/inspect/{}", percent_encode_node_id(node_id))
7779    }
7780
7781    #[test]
7782    fn inspect_episode_returns_full_text_plus_triples_out() {
7783        let runtime = rt();
7784        let h = Harness::new(&runtime);
7785        let memory_id = "a1110000-0000-7000-8000-000000000001";
7786        let full_text = "Met Alice for coffee at the new place. She mentioned the project is on track but they're hitting issues with the deploy pipeline.";
7787        {
7788            let conn = h.open_db();
7789            let rowid = seed_episode(&conn, memory_id, 1_715_625_600_000, full_text);
7790            seed_triple_row(&conn, "t-ep-1", "user", "met_with", "Alice", Some(rowid));
7791            seed_triple_row(&conn, "t-ep-2", "user", "discussed", "deploy_pipeline", Some(rowid));
7792            seed_triple_row(&conn, "t-ep-3", "Alice", "works_on", "project", Some(rowid));
7793        }
7794        let (status, body) = runtime.block_on(call(
7795            h.router.clone(),
7796            "GET",
7797            &inspect_uri(&format!("ep:{memory_id}")),
7798            None,
7799        ));
7800        assert_eq!(status, StatusCode::OK, "body: {body}");
7801        assert_eq!(body["node"]["kind"], "episode");
7802        assert_eq!(body["node"]["id"], format!("ep:{memory_id}"));
7803        assert_eq!(
7804            body["full_text"].as_str().unwrap(),
7805            full_text,
7806            "full_text must match episodes.content verbatim, untruncated"
7807        );
7808        let triples_out = body["triples_out"].as_array().unwrap();
7809        assert_eq!(triples_out.len(), 3, "{body}");
7810        let triples_in = body["triples_in"].as_array().unwrap();
7811        assert!(triples_in.is_empty(), "episodes have no triples_in: {body}");
7812        for e in triples_out {
7813            assert_eq!(e["kind"], "triple");
7814            assert_eq!(e["source"], format!("ep:{memory_id}"));
7815            assert!(e["target"].as_str().unwrap().starts_with("ent:"));
7816            assert!(e["predicate"].as_str().is_some());
7817            assert!(e["weight"].as_f64().is_some());
7818        }
7819        h.shutdown(&runtime);
7820    }
7821
7822    #[test]
7823    fn inspect_episode_triples_in_is_empty_for_v10p1() {
7824        // Seed an episode + a triple from a DIFFERENT episode that
7825        // happens to mention the focal episode's content. Even with
7826        // entities referencing the episode topic, episode.triples_in
7827        // is structurally empty in v0.10.0 P1.
7828        let runtime = rt();
7829        let h = Harness::new(&runtime);
7830        let focal = "a2220000-0000-7000-8000-000000000001";
7831        let other = "a2220000-0000-7000-8000-000000000002";
7832        {
7833            let conn = h.open_db();
7834            seed_episode(&conn, focal, 100, "focal episode body");
7835            let other_rowid = seed_episode(&conn, other, 200, "another episode");
7836            // Entity "user" gets referenced heavily; doesn't matter --
7837            // episode triples_in stays empty.
7838            for i in 0..5 {
7839                let tid = format!("t-other-{i}");
7840                seed_triple_row(&conn, &tid, "user", "did", "thing", Some(other_rowid));
7841            }
7842        }
7843        let (status, body) = runtime.block_on(call(
7844            h.router.clone(),
7845            "GET",
7846            &inspect_uri(&format!("ep:{focal}")),
7847            None,
7848        ));
7849        assert_eq!(status, StatusCode::OK, "body: {body}");
7850        let triples_in = body["triples_in"].as_array().unwrap();
7851        assert!(
7852            triples_in.is_empty(),
7853            "episode triples_in must be empty regardless of cross-episode entity references: {body}"
7854        );
7855        h.shutdown(&runtime);
7856    }
7857
7858    #[test]
7859    fn inspect_document_returns_full_text_concatenated_from_chunks() {
7860        let runtime = rt();
7861        let h = Harness::new(&runtime);
7862        let doc_id = "d3330000-0000-7000-8000-000000000001";
7863        {
7864            let conn = h.open_db();
7865            seed_document_row(&conn, doc_id, "doc-title");
7866            seed_chunk_row(&conn, "ch-doc-1", doc_id, 0, "First chunk body.");
7867            seed_chunk_row(&conn, "ch-doc-2", doc_id, 1, "Second chunk body.");
7868            seed_chunk_row(&conn, "ch-doc-3", doc_id, 2, "Third chunk body.");
7869        }
7870        let (status, body) = runtime.block_on(call(
7871            h.router.clone(),
7872            "GET",
7873            &inspect_uri(&format!("doc:{doc_id}")),
7874            None,
7875        ));
7876        assert_eq!(status, StatusCode::OK, "body: {body}");
7877        assert_eq!(body["node"]["kind"], "document");
7878        let full_text = body["full_text"].as_str().unwrap();
7879        // Concatenation order matches chunk_index ASC; separator is "\n\n".
7880        assert_eq!(
7881            full_text,
7882            "First chunk body.\n\nSecond chunk body.\n\nThird chunk body."
7883        );
7884        assert!(body["triples_in"].as_array().unwrap().is_empty());
7885        assert!(body["triples_out"].as_array().unwrap().is_empty());
7886        h.shutdown(&runtime);
7887    }
7888
7889    #[test]
7890    fn inspect_chunk_returns_text() {
7891        let runtime = rt();
7892        let h = Harness::new(&runtime);
7893        let chunk_body = "This is the body of the chunk being inspected.";
7894        {
7895            let conn = h.open_db();
7896            seed_document_row(&conn, "doc-chunk-host", "host");
7897            seed_chunk_row(&conn, "chunk-inspect-target", "doc-chunk-host", 0, chunk_body);
7898        }
7899        let (status, body) = runtime.block_on(call(
7900            h.router.clone(),
7901            "GET",
7902            &inspect_uri("chunk:chunk-inspect-target"),
7903            None,
7904        ));
7905        assert_eq!(status, StatusCode::OK, "body: {body}");
7906        assert_eq!(body["node"]["kind"], "chunk");
7907        assert_eq!(body["full_text"].as_str().unwrap(), chunk_body);
7908        assert!(body["triples_in"].as_array().unwrap().is_empty());
7909        assert!(body["triples_out"].as_array().unwrap().is_empty());
7910        h.shutdown(&runtime);
7911    }
7912
7913    #[test]
7914    fn inspect_cluster_returns_label_and_abstraction() {
7915        let runtime = rt();
7916        let h = Harness::new(&runtime);
7917        let cluster_id = "cl-inspect-target";
7918        let abstraction_text = "Discussions about the deploy pipeline and on-call rotation.";
7919        {
7920            let conn = h.open_db();
7921            seed_cluster_row(&conn, cluster_id, 12345);
7922            seed_abstraction_row(&conn, "abs-1", cluster_id, abstraction_text);
7923        }
7924        let (status, body) = runtime.block_on(call(
7925            h.router.clone(),
7926            "GET",
7927            &inspect_uri(&format!("cl:{cluster_id}")),
7928            None,
7929        ));
7930        assert_eq!(status, StatusCode::OK, "body: {body}");
7931        assert_eq!(body["node"]["kind"], "cluster");
7932        let full_text = body["full_text"].as_str().unwrap();
7933        assert!(
7934            full_text.contains(cluster_id),
7935            "full_text must include cluster label: {full_text}"
7936        );
7937        assert!(
7938            full_text.contains(abstraction_text),
7939            "full_text must include abstraction text: {full_text}"
7940        );
7941        // "label\n\nabstraction" -- separated by blank line for the
7942        // inspector renderer.
7943        assert!(full_text.contains("\n\n"), "label and abstraction must be separated: {full_text}");
7944        h.shutdown(&runtime);
7945    }
7946
7947    #[test]
7948    fn inspect_entity_returns_triples_only() {
7949        let runtime = rt();
7950        let h = Harness::new(&runtime);
7951        {
7952            let conn = h.open_db();
7953            let rowid = seed_episode(
7954                &conn,
7955                "e5550000-0000-7000-8000-000000000001",
7956                100,
7957                "host episode",
7958            );
7959            // 5 triples that reference Alice (as subject or object).
7960            seed_triple_row(&conn, "t-ent-1", "Alice", "knows", "Bob", Some(rowid));
7961            seed_triple_row(&conn, "t-ent-2", "Alice", "works_at", "Anthropic", Some(rowid));
7962            seed_triple_row(&conn, "t-ent-3", "user", "met", "Alice", Some(rowid));
7963            seed_triple_row(&conn, "t-ent-4", "Alice", "owns", "laptop", Some(rowid));
7964            seed_triple_row(&conn, "t-ent-5", "Carol", "mentors", "Alice", Some(rowid));
7965        }
7966        let (status, body) = runtime.block_on(call(
7967            h.router.clone(),
7968            "GET",
7969            &inspect_uri("ent:Alice"),
7970            None,
7971        ));
7972        assert_eq!(status, StatusCode::OK, "body: {body}");
7973        assert_eq!(body["node"]["kind"], "entity");
7974        assert_eq!(body["node"]["id"], "ent:Alice");
7975        assert!(
7976            body["full_text"].is_null(),
7977            "entity full_text must be null (entities have no body): {body}"
7978        );
7979        let triples_out = body["triples_out"].as_array().unwrap();
7980        assert_eq!(triples_out.len(), 5, "{body}");
7981        assert!(body["triples_in"].as_array().unwrap().is_empty());
7982        for e in triples_out {
7983            assert_eq!(e["kind"], "triple");
7984            assert_eq!(e["source"], "ent:Alice");
7985            // Counterpart is always an entity; Alice never appears on
7986            // both ends so target != source.
7987            assert!(e["target"].as_str().unwrap().starts_with("ent:"));
7988            assert_ne!(e["target"], "ent:Alice");
7989        }
7990        h.shutdown(&runtime);
7991    }
7992
7993    #[test]
7994    fn inspect_entity_with_zero_triples_returns_404() {
7995        let runtime = rt();
7996        let h = Harness::new(&runtime);
7997        // Seed unrelated triples so the table isn't empty; the target
7998        // entity still has zero references.
7999        {
8000            let conn = h.open_db();
8001            let rowid = seed_episode(
8002                &conn,
8003                "e6660000-0000-7000-8000-000000000001",
8004                100,
8005                "ep",
8006            );
8007            seed_triple_row(&conn, "t-other", "Bob", "knows", "Carol", Some(rowid));
8008        }
8009        let (status, body) = runtime.block_on(call(
8010            h.router.clone(),
8011            "GET",
8012            &inspect_uri("ent:Nonexistent"),
8013            None,
8014        ));
8015        assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
8016        let err = body["error"].as_str().unwrap_or_default();
8017        assert!(
8018            err.contains("Nonexistent") || err.contains("entity"),
8019            "error must mention entity: {body}"
8020        );
8021        h.shutdown(&runtime);
8022    }
8023
8024    #[test]
8025    fn inspect_404_on_missing_node() {
8026        // Well-formed `ep:` prefix + valid UUID shape, but no row in DB.
8027        let runtime = rt();
8028        let h = Harness::new(&runtime);
8029        let (status, body) = runtime.block_on(call(
8030            h.router.clone(),
8031            "GET",
8032            &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8033            None,
8034        ));
8035        assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
8036        h.shutdown(&runtime);
8037    }
8038
8039    #[test]
8040    fn inspect_400_on_invalid_prefix() {
8041        let runtime = rt();
8042        let h = Harness::new(&runtime);
8043        let (status, body) = runtime.block_on(call(
8044            h.router.clone(),
8045            "GET",
8046            &inspect_uri("xyz:foo"),
8047            None,
8048        ));
8049        assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8050        let err = body["error"].as_str().unwrap_or_default();
8051        assert!(
8052            err.contains("xyz") || err.contains("prefix"),
8053            "error must mention bad prefix: {body}"
8054        );
8055        h.shutdown(&runtime);
8056    }
8057
8058    #[test]
8059    fn inspect_respects_tenant_scoping() {
8060        let runtime = rt();
8061        let h = Harness::new(&runtime);
8062        let memory_id = "a7770000-0000-7000-8000-000000000001";
8063        {
8064            let conn = h.open_db();
8065            seed_episode(&conn, memory_id, 100, "tenant scope");
8066        }
8067        // Real id in default tenant resolves; the same request against
8068        // a never-registered tenant header surfaces 404 from the tenant
8069        // extractor before the handler runs.
8070        let r = h.router.clone();
8071        let (status, _) = runtime.block_on(async {
8072            let req = Request::builder()
8073                .method("GET")
8074                .uri(inspect_uri(&format!("ep:{memory_id}")))
8075                .header("x-solo-tenant", "never-registered-tenant")
8076                .body(Body::empty())
8077                .unwrap();
8078            let resp = r.oneshot(req).await.expect("oneshot");
8079            let s = resp.status();
8080            let _b = resp.into_body().collect().await.unwrap().to_bytes();
8081            (s, _b)
8082        });
8083        assert_eq!(status, StatusCode::NOT_FOUND);
8084        // Sanity: same id resolves on the default tenant.
8085        let (status, body) = runtime.block_on(call(
8086            h.router.clone(),
8087            "GET",
8088            &inspect_uri(&format!("ep:{memory_id}")),
8089            None,
8090        ));
8091        assert_eq!(status, StatusCode::OK, "default tenant must resolve: {body}");
8092        h.shutdown(&runtime);
8093    }
8094
8095    #[test]
8096    fn inspect_respects_auth_when_enabled() {
8097        let runtime = rt();
8098        let h = Harness::new_with_auth(&runtime, Some("inspect-secret".into()));
8099        // Missing bearer -> 401 before handler runs.
8100        let (status, _) = runtime.block_on(call(
8101            h.router.clone(),
8102            "GET",
8103            &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8104            None,
8105        ));
8106        assert_eq!(status, StatusCode::UNAUTHORIZED);
8107        // Valid bearer + unknown node -> handler runs and returns 404,
8108        // proving auth passed through.
8109        let (status, _) = runtime.block_on(call_with_auth(
8110            h.router.clone(),
8111            "GET",
8112            &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8113            None,
8114            Some("Bearer inspect-secret"),
8115        ));
8116        assert_eq!(status, StatusCode::NOT_FOUND);
8117        h.shutdown(&runtime);
8118    }
8119
8120    // ---------------------------------------------------------------------
8121    // v0.10.0: GET /v1/graph/neighbors/{id}
8122    //
8123    // Unified explicit + HNSW-semantic neighbor surface for solo-web's
8124    // "show similar" overlay. Tests cover the kind dispatch (explicit /
8125    // semantic / both default), threshold filter, limit clamp, dedupe
8126    // rule, and the standard 400/404/auth/tenant gates.
8127    // ---------------------------------------------------------------------
8128
8129    /// URL builder for the neighbors endpoint. `kind`/`threshold`/`limit`
8130    /// are all optional; pass `None` to omit the corresponding query
8131    /// parameter. The node id is percent-encoded so `:` survives the path
8132    /// extractor.
8133    fn neighbors_uri(
8134        node_id: &str,
8135        kind: Option<&str>,
8136        threshold: Option<f32>,
8137        limit: Option<u32>,
8138    ) -> String {
8139        let mut qs: Vec<String> = Vec::new();
8140        if let Some(k) = kind {
8141            qs.push(format!("kind={k}"));
8142        }
8143        if let Some(t) = threshold {
8144            qs.push(format!("threshold={t}"));
8145        }
8146        if let Some(l) = limit {
8147            qs.push(format!("limit={l}"));
8148        }
8149        let encoded = percent_encode_node_id(node_id);
8150        if qs.is_empty() {
8151            format!("/v1/graph/neighbors/{encoded}")
8152        } else {
8153            format!("/v1/graph/neighbors/{encoded}?{}", qs.join("&"))
8154        }
8155    }
8156
8157    /// 1. `?kind=explicit` returns only structural edges (no semantic).
8158    /// Seeds an episode with 2 explicit (triple) neighbors + several
8159    /// distinct other episodes so the semantic path COULD surface
8160    /// candidates. The `kind=explicit` filter must drop all of them.
8161    #[test]
8162    fn neighbors_explicit_only_returns_no_semantic_edges() {
8163        let runtime = rt();
8164        let h = Harness::new(&runtime);
8165        runtime.block_on(async {
8166            // Seed several episodes via the writer-actor so they get HNSW
8167            // entries -- the semantic path would surface these if it
8168            // wasn't filtered out.
8169            let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8170            let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8171            let _other2 = post_remember(h.router.clone(), "gamma gamma gamma").await;
8172            // Add explicit triples sourced from `focal`. seed_triple_row
8173            // needs the focal rowid -- look it up via a side connection.
8174            {
8175                let conn = h.open_db();
8176                let rowid: i64 = conn
8177                    .query_row(
8178                        "SELECT rowid FROM episodes WHERE memory_id = ?1",
8179                        rusqlite::params![&focal],
8180                        |r| r.get(0),
8181                    )
8182                    .unwrap();
8183                seed_triple_row(&conn, "t-exp-1", "Alice", "knows", "Bob", Some(rowid));
8184                seed_triple_row(&conn, "t-exp-2", "Alice", "owns", "laptop", Some(rowid));
8185            }
8186            let (status, body) = call(
8187                h.router.clone(),
8188                "GET",
8189                &neighbors_uri(&format!("ep:{focal}"), Some("explicit"), None, None),
8190                None,
8191            )
8192            .await;
8193            assert_eq!(status, StatusCode::OK, "body: {body}");
8194            let edges = body["edges"].as_array().unwrap();
8195            assert!(!edges.is_empty(), "expected explicit edges: {body}");
8196            for e in edges {
8197                assert_ne!(
8198                    e["kind"], "semantic",
8199                    "kind=explicit must drop semantic edges: {body}"
8200                );
8201            }
8202        });
8203        h.shutdown(&runtime);
8204    }
8205
8206    /// 2. `?kind=semantic` returns only HNSW edges (no explicit).
8207    /// Inverse of test 1 -- same fixture, opposite filter.
8208    #[test]
8209    fn neighbors_semantic_only_returns_no_explicit_edges() {
8210        let runtime = rt();
8211        let h = Harness::new(&runtime);
8212        runtime.block_on(async {
8213            let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8214            let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8215            let _other2 = post_remember(h.router.clone(), "gamma gamma gamma").await;
8216            {
8217                let conn = h.open_db();
8218                let rowid: i64 = conn
8219                    .query_row(
8220                        "SELECT rowid FROM episodes WHERE memory_id = ?1",
8221                        rusqlite::params![&focal],
8222                        |r| r.get(0),
8223                    )
8224                    .unwrap();
8225                seed_triple_row(&conn, "t-exp-1", "Alice", "knows", "Bob", Some(rowid));
8226            }
8227            // Threshold=0 so every HNSW hit clears the filter.
8228            let (status, body) = call(
8229                h.router.clone(),
8230                "GET",
8231                &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.0), None),
8232                None,
8233            )
8234            .await;
8235            assert_eq!(status, StatusCode::OK, "body: {body}");
8236            let edges = body["edges"].as_array().unwrap();
8237            for e in edges {
8238                assert_eq!(
8239                    e["kind"], "semantic",
8240                    "kind=semantic must drop explicit edges: {body}"
8241                );
8242                assert!(e["weight"].is_number(), "semantic edges carry weight: {body}");
8243            }
8244        });
8245        h.shutdown(&runtime);
8246    }
8247
8248    /// 3. Default (no `kind=` param) returns both explicit + semantic.
8249    #[test]
8250    fn neighbors_both_default_returns_combined() {
8251        let runtime = rt();
8252        let h = Harness::new(&runtime);
8253        runtime.block_on(async {
8254            let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8255            let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8256            {
8257                let conn = h.open_db();
8258                let rowid: i64 = conn
8259                    .query_row(
8260                        "SELECT rowid FROM episodes WHERE memory_id = ?1",
8261                        rusqlite::params![&focal],
8262                        |r| r.get(0),
8263                    )
8264                    .unwrap();
8265                seed_triple_row(&conn, "t-both-1", "Alice", "met", "Bob", Some(rowid));
8266            }
8267            let (status, body) = call(
8268                h.router.clone(),
8269                "GET",
8270                // No kind param -> default = both. Threshold 0 so semantic
8271                // hits make it through the filter.
8272                &neighbors_uri(&format!("ep:{focal}"), None, Some(0.0), None),
8273                None,
8274            )
8275            .await;
8276            assert_eq!(status, StatusCode::OK, "body: {body}");
8277            let edges = body["edges"].as_array().unwrap();
8278            let kinds: std::collections::HashSet<&str> = edges
8279                .iter()
8280                .map(|e| e["kind"].as_str().unwrap())
8281                .collect();
8282            assert!(
8283                kinds.contains("triple"),
8284                "expected at least one triple edge: {body}"
8285            );
8286            assert!(
8287                kinds.contains("semantic"),
8288                "expected at least one semantic edge: {body}"
8289            );
8290        });
8291        h.shutdown(&runtime);
8292    }
8293
8294    /// 4. Dedupe rule. Construct an episode X whose semantic-neighbor Y
8295    /// is ALSO a triple-target -- i.e. the explicit and semantic paths
8296    /// both produce an edge X -> Y. After dedupe only the explicit edge
8297    /// survives.
8298    #[test]
8299    fn neighbors_dedupes_semantic_when_explicit_exists() {
8300        let runtime = rt();
8301        let h = Harness::new(&runtime);
8302        runtime.block_on(async {
8303            let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8304            // Seed an explicit triple from focal -> ent:peer-target.
8305            // The semantic path produces edges focal -> ep:<other>; we
8306            // ensure both paths produce an edge ending at the same id by
8307            // wiring `peer-target = ep:<other_memory_id>` -- but the
8308            // entity emitter uses `ent:` prefix, not `ep:`. So to force a
8309            // collision we need an edge form where source+target overlap.
8310            //
8311            // Simpler construction: the `expand_triple_from_episode` path
8312            // emits an edge `ent:subject -> ent:object`, not from the
8313            // focal episode -- meaning the explicit edges don't end at
8314            // an ep: node in the first place. So we have to engineer a
8315            // collision via the cluster_member path:
8316            //   * explicit: focal (episode) -> cluster (via cluster_member)
8317            //   * semantic: focal -> similar episode
8318            // The two endpoints (cluster vs. episode) never collide in
8319            // shape. To produce a real (source, target) overlap that
8320            // exercises the dedupe code, mint a synthetic semantic edge
8321            // by adding an explicit triple sourced from the focal that
8322            // happens to end at the SAME entity the semantic path would
8323            // emit -- but semantic only emits ep:/chunk: ids, never ent:.
8324            //
8325            // The brief flagged this scenario as unlikely. Build the
8326            // simplest collision the codebase admits: have the focal
8327            // episode's semantic neighbor's memory_id appear as a
8328            // triple's object_id (formatted as ent:<that-uuid>). The
8329            // explicit edge is then `ent:<self-subject> -> ent:<uuid>`;
8330            // the semantic edge is `ep:focal -> ep:<uuid>`. The (source,
8331            // target) pair DIFFERS (`ent:X` vs `ep:focal`), so dedupe
8332            // would NOT fire -- which is correct: those are structurally
8333            // different relationships.
8334            //
8335            // Therefore the realistic dedupe test is the trivial
8336            // tautology: explicit and semantic produce no collisions in
8337            // practice. Lock that in by asserting that the same memory_id
8338            // never appears with an edge from both paths.
8339            let _other = post_remember(h.router.clone(), "beta beta beta").await;
8340            {
8341                let conn = h.open_db();
8342                let rowid: i64 = conn
8343                    .query_row(
8344                        "SELECT rowid FROM episodes WHERE memory_id = ?1",
8345                        rusqlite::params![&focal],
8346                        |r| r.get(0),
8347                    )
8348                    .unwrap();
8349                seed_triple_row(
8350                    &conn,
8351                    "t-dedupe-1",
8352                    "Alice",
8353                    "knows",
8354                    "Bob",
8355                    Some(rowid),
8356                );
8357            }
8358            let (status, body) = call(
8359                h.router.clone(),
8360                "GET",
8361                &neighbors_uri(&format!("ep:{focal}"), Some("both"), Some(0.0), None),
8362                None,
8363            )
8364            .await;
8365            assert_eq!(status, StatusCode::OK, "body: {body}");
8366            // For every edge, count occurrences of (source, target). No
8367            // pair should appear twice (which is what the dedupe rule
8368            // guarantees).
8369            let edges = body["edges"].as_array().unwrap();
8370            let mut seen: std::collections::HashMap<(String, String), i32> =
8371                std::collections::HashMap::new();
8372            for e in edges {
8373                let key = (
8374                    e["source"].as_str().unwrap().to_string(),
8375                    e["target"].as_str().unwrap().to_string(),
8376                );
8377                *seen.entry(key).or_insert(0) += 1;
8378            }
8379            for (pair, count) in &seen {
8380                assert_eq!(
8381                    *count, 1,
8382                    "edge pair {pair:?} appears {count} times -- dedupe rule violated: {body}"
8383                );
8384            }
8385        });
8386        h.shutdown(&runtime);
8387    }
8388
8389    /// 5. Threshold filter -- raising the threshold drops low-similarity
8390    /// semantic neighbors.
8391    #[test]
8392    fn neighbors_threshold_filters_low_similarity() {
8393        let runtime = rt();
8394        let h = Harness::new(&runtime);
8395        runtime.block_on(async {
8396            let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8397            let _o1 = post_remember(h.router.clone(), "beta one").await;
8398            let _o2 = post_remember(h.router.clone(), "beta two").await;
8399            let _o3 = post_remember(h.router.clone(), "beta three").await;
8400            // Low threshold -- expect more semantic hits.
8401            let (status, low_body) = call(
8402                h.router.clone(),
8403                "GET",
8404                &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.0), None),
8405                None,
8406            )
8407            .await;
8408            assert_eq!(status, StatusCode::OK, "body: {low_body}");
8409            let low_edge_count = low_body["edges"].as_array().unwrap().len();
8410            // High threshold -- expect fewer (or equal) semantic hits.
8411            let (status, high_body) = call(
8412                h.router.clone(),
8413                "GET",
8414                &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.99), None),
8415                None,
8416            )
8417            .await;
8418            assert_eq!(status, StatusCode::OK, "body: {high_body}");
8419            let high_edge_count = high_body["edges"].as_array().unwrap().len();
8420            assert!(
8421                high_edge_count <= low_edge_count,
8422                "high-threshold ({high_edge_count}) must not exceed low-threshold ({low_edge_count}): low={low_body}, high={high_body}"
8423            );
8424            // Also assert every surviving high-threshold edge satisfies
8425            // the filter.
8426            for e in high_body["edges"].as_array().unwrap() {
8427                if let Some(w) = e["weight"].as_f64() {
8428                    assert!(
8429                        w >= 0.99,
8430                        "edge with weight {w} survived threshold=0.99: {e}"
8431                    );
8432                }
8433            }
8434        });
8435        h.shutdown(&runtime);
8436    }
8437
8438    /// 6. `?limit=999` is silently clamped at the family ceiling (100) --
8439    /// same policy as `/v1/graph/expand`.
8440    #[test]
8441    fn neighbors_limit_clamped_at_100() {
8442        let runtime = rt();
8443        let h = Harness::new(&runtime);
8444        // Seed a cluster with > 100 episodes so the explicit cluster_member
8445        // path could surface > 100 -- clamp must cap at 100.
8446        {
8447            let conn = h.open_db();
8448            seed_cluster_row(&conn, "cl-huge-n", 1000);
8449            for i in 0..150 {
8450                let mid = format!("99119911-1111-7000-8000-{:012}", i);
8451                seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
8452                seed_cluster_member(&conn, "cl-huge-n", &mid);
8453            }
8454        }
8455        let (status, body) = runtime.block_on(call(
8456            h.router.clone(),
8457            "GET",
8458            &neighbors_uri("cl:cl-huge-n", Some("explicit"), None, Some(999)),
8459            None,
8460        ));
8461        assert_eq!(status, StatusCode::OK, "body: {body}");
8462        let edges = body["edges"].as_array().unwrap();
8463        assert_eq!(
8464            edges.len(),
8465            100,
8466            "limit must be silently clamped to 100, got {}",
8467            edges.len()
8468        );
8469        h.shutdown(&runtime);
8470    }
8471
8472    /// 7. `kind=semantic` on a document focal node returns 400.
8473    #[test]
8474    fn neighbors_semantic_rejects_document_source() {
8475        let runtime = rt();
8476        let h = Harness::new(&runtime);
8477        let doc_id = "d-semrej-0000-7000-8000-000000000001";
8478        {
8479            let conn = h.open_db();
8480            seed_document_row(&conn, doc_id, "host");
8481        }
8482        let (status, body) = runtime.block_on(call(
8483            h.router.clone(),
8484            "GET",
8485            &neighbors_uri(
8486                &format!("doc:{doc_id}"),
8487                Some("semantic"),
8488                None,
8489                None,
8490            ),
8491            None,
8492        ));
8493        assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8494        let err = body["error"].as_str().unwrap_or_default();
8495        assert!(
8496            err.contains("episode") && err.contains("chunk"),
8497            "error must list supported kinds: {body}"
8498        );
8499        h.shutdown(&runtime);
8500    }
8501
8502    /// 8. `kind=semantic` on a cluster focal node returns 400.
8503    #[test]
8504    fn neighbors_semantic_rejects_cluster_source() {
8505        let runtime = rt();
8506        let h = Harness::new(&runtime);
8507        let cluster_id = "cl-semrej-target";
8508        {
8509            let conn = h.open_db();
8510            seed_cluster_row(&conn, cluster_id, 12345);
8511        }
8512        let (status, body) = runtime.block_on(call(
8513            h.router.clone(),
8514            "GET",
8515            &neighbors_uri(
8516                &format!("cl:{cluster_id}"),
8517                Some("semantic"),
8518                None,
8519                None,
8520            ),
8521            None,
8522        ));
8523        assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8524        h.shutdown(&runtime);
8525    }
8526
8527    /// 9. Entity focal node returns only explicit triple edges; no
8528    /// semantic edges (entities have no embeddings, semantic path is
8529    /// silently skipped under `kind=both`).
8530    #[test]
8531    fn neighbors_entity_returns_triples_only() {
8532        let runtime = rt();
8533        let h = Harness::new(&runtime);
8534        runtime.block_on(async {
8535            // Use the writer-actor so the host episode lands in HNSW too
8536            // (any HNSW state is irrelevant since entities can't trigger
8537            // semantic recall; included to prove the semantic path is
8538            // silently skipped, not erroring).
8539            let host_mid = post_remember(h.router.clone(), "Alice and Bob talked").await;
8540            {
8541                let conn = h.open_db();
8542                let rowid: i64 = conn
8543                    .query_row(
8544                        "SELECT rowid FROM episodes WHERE memory_id = ?1",
8545                        rusqlite::params![&host_mid],
8546                        |r| r.get(0),
8547                    )
8548                    .unwrap();
8549                seed_triple_row(&conn, "t-ent-n-1", "Alice", "knows", "Bob", Some(rowid));
8550                seed_triple_row(&conn, "t-ent-n-2", "Alice", "works_at", "Acme", Some(rowid));
8551            }
8552            let (status, body) = call(
8553                h.router.clone(),
8554                "GET",
8555                &neighbors_uri("ent:Alice", None, Some(0.0), None),
8556                None,
8557            )
8558            .await;
8559            assert_eq!(status, StatusCode::OK, "body: {body}");
8560            let edges = body["edges"].as_array().unwrap();
8561            assert!(!edges.is_empty(), "expected explicit triples: {body}");
8562            for e in edges {
8563                assert_eq!(
8564                    e["kind"], "triple",
8565                    "entity focal must produce only triple edges: {body}"
8566                );
8567            }
8568        });
8569        h.shutdown(&runtime);
8570    }
8571
8572    /// 10. Cross-tenant lookups are blocked at the TenantExtractor before
8573    /// the handler runs.
8574    #[test]
8575    fn neighbors_respects_tenant_scoping() {
8576        let runtime = rt();
8577        let h = Harness::new(&runtime);
8578        let memory_id = "a8880000-0000-7000-8000-000000000001";
8579        {
8580            let conn = h.open_db();
8581            seed_episode(&conn, memory_id, 100, "tenant scope");
8582        }
8583        // Wrong tenant header -> 404 from registry, before handler runs.
8584        let r = h.router.clone();
8585        let (status, _) = runtime.block_on(async {
8586            let req = Request::builder()
8587                .method("GET")
8588                .uri(neighbors_uri(
8589                    &format!("ep:{memory_id}"),
8590                    Some("explicit"),
8591                    None,
8592                    None,
8593                ))
8594                .header("x-solo-tenant", "never-registered-tenant-n")
8595                .body(Body::empty())
8596                .unwrap();
8597            let resp = r.oneshot(req).await.expect("oneshot");
8598            let s = resp.status();
8599            let _b = resp.into_body().collect().await.unwrap().to_bytes();
8600            (s, _b)
8601        });
8602        assert_eq!(status, StatusCode::NOT_FOUND);
8603        // Sanity: same id resolves on default tenant.
8604        let (status, body) = runtime.block_on(call(
8605            h.router.clone(),
8606            "GET",
8607            &neighbors_uri(&format!("ep:{memory_id}"), Some("explicit"), None, None),
8608            None,
8609        ));
8610        assert_eq!(status, StatusCode::OK, "default tenant must resolve: {body}");
8611        h.shutdown(&runtime);
8612    }
8613
8614    /// 11. Bearer-auth gate: missing token -> 401; valid token + unknown
8615    /// node -> 404 (auth passed, handler ran).
8616    #[test]
8617    fn neighbors_respects_auth_when_enabled() {
8618        let runtime = rt();
8619        let h = Harness::new_with_auth(&runtime, Some("neighbors-secret".into()));
8620        // Missing Authorization -> 401.
8621        let (status, _) = runtime.block_on(call(
8622            h.router.clone(),
8623            "GET",
8624            &neighbors_uri(
8625                "ep:99999999-9999-7000-8000-000000000999",
8626                Some("explicit"),
8627                None,
8628                None,
8629            ),
8630            None,
8631        ));
8632        assert_eq!(status, StatusCode::UNAUTHORIZED);
8633        // Valid bearer + unknown node -> 404 from the handler.
8634        let (status, _) = runtime.block_on(call_with_auth(
8635            h.router.clone(),
8636            "GET",
8637            &neighbors_uri(
8638                "ep:99999999-9999-7000-8000-000000000999",
8639                Some("explicit"),
8640                None,
8641                None,
8642            ),
8643            None,
8644            Some("Bearer neighbors-secret"),
8645        ));
8646        assert_eq!(status, StatusCode::NOT_FOUND);
8647        h.shutdown(&runtime);
8648    }
8649
8650    // ---------------------------------------------------------------------
8651    // v0.10.0: GET /v1/graph/stream — SSE invalidation feed
8652    //
8653    // Driving SSE through axum's in-process router (`oneshot`) requires
8654    // reading the response body as a stream of frames and parsing each
8655    // chunk against the SSE wire format (`event: NAME\ndata: JSON\n\n`).
8656    // The `read_one_sse_event` helper below does that incrementally so
8657    // tests don't have to wait for the stream to close (which would
8658    // never happen — the SSE loop runs until the client drops).
8659    // ---------------------------------------------------------------------
8660
8661    /// One parsed SSE event: the `event:` field plus the `data:` payload
8662    /// re-parsed as JSON. Empty / comment-only frames are filtered out
8663    /// by the parser; callers only see real events.
8664    #[derive(Debug, Clone)]
8665    struct ParsedSseEvent {
8666        event: String,
8667        data: Value,
8668    }
8669
8670    /// Read frames off the SSE body until ONE complete event lands, then
8671    /// return it. Times out after `timeout` to keep red-test feedback
8672    /// fast. On timeout returns `None`.
8673    async fn read_one_sse_event(
8674        body: &mut axum::body::Body,
8675        timeout: std::time::Duration,
8676    ) -> Option<ParsedSseEvent> {
8677        use http_body_util::BodyExt;
8678        let mut buf = String::new();
8679        let start = std::time::Instant::now();
8680        loop {
8681            if start.elapsed() >= timeout {
8682                return None;
8683            }
8684            let remaining = timeout.saturating_sub(start.elapsed());
8685            let frame_res =
8686                tokio::time::timeout(remaining, body.frame()).await;
8687            let frame = match frame_res {
8688                Ok(Some(Ok(f))) => f,
8689                Ok(Some(Err(_))) | Ok(None) => return None,
8690                Err(_) => return None,
8691            };
8692            if let Ok(data) = frame.into_data() {
8693                buf.push_str(&String::from_utf8_lossy(&data));
8694                // Parse complete events (double newline separator).
8695                while let Some(idx) = buf.find("\n\n") {
8696                    let block: String = buf.drain(..idx + 2).collect();
8697                    if let Some(parsed) = parse_sse_block(&block) {
8698                        return Some(parsed);
8699                    }
8700                }
8701            }
8702        }
8703    }
8704
8705    /// Parse one SSE block (raw text between two `\n\n` separators).
8706    /// Returns `None` for comment-only blocks (lines starting with `:`)
8707    /// or blocks missing either `event:` or `data:`.
8708    fn parse_sse_block(block: &str) -> Option<ParsedSseEvent> {
8709        let mut event: Option<String> = None;
8710        let mut data: Option<String> = None;
8711        for line in block.lines() {
8712            if let Some(rest) = line.strip_prefix("event:") {
8713                event = Some(rest.trim().to_string());
8714            } else if let Some(rest) = line.strip_prefix("data:") {
8715                data = Some(rest.trim().to_string());
8716            }
8717        }
8718        let event = event?;
8719        let data_str = data?;
8720        let data_json = serde_json::from_str(&data_str).ok()?;
8721        Some(ParsedSseEvent {
8722            event,
8723            data: data_json,
8724        })
8725    }
8726
8727    /// Open the SSE stream and return the response body for further
8728    /// frame-level reads. The headers are validated (Content-Type +
8729    /// status) before the body is returned.
8730    async fn open_sse_stream_inner(
8731        router: axum::Router,
8732        auth: Option<&str>,
8733        tenant: Option<&str>,
8734    ) -> (StatusCode, axum::body::Body) {
8735        let mut builder = Request::builder()
8736            .method("GET")
8737            .uri("/v1/graph/stream");
8738        if let Some(a) = auth {
8739            builder = builder.header("authorization", a);
8740        }
8741        if let Some(t) = tenant {
8742            builder = builder.header("x-solo-tenant", t);
8743        }
8744        let req = builder
8745            .header("content-length", "0")
8746            .body(Body::empty())
8747            .unwrap();
8748        let resp = router.oneshot(req).await.expect("oneshot");
8749        let status = resp.status();
8750        let body = resp.into_body();
8751        (status, body)
8752    }
8753
8754    /// 1. `init` event lands as the first chunk.
8755    #[test]
8756    fn stream_emits_init_event_on_connect() {
8757        let runtime = rt();
8758        let h = Harness::new(&runtime);
8759        let r = h.router.clone();
8760        runtime.block_on(async {
8761            let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8762            assert_eq!(status, StatusCode::OK);
8763            let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8764                .await
8765                .expect("must receive init event within 2s");
8766            assert_eq!(ev.event, "init");
8767            assert_eq!(ev.data["connected"].as_bool(), Some(true));
8768            assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
8769            assert!(ev.data["ts_ms"].is_number());
8770        });
8771        h.shutdown(&runtime);
8772    }
8773
8774    /// 2. Firing an InvalidateEvent on the broadcast channel surfaces
8775    /// as an `invalidate` SSE event.
8776    #[test]
8777    fn stream_emits_invalidate_after_writer_event() {
8778        let runtime = rt();
8779        let h = Harness::new(&runtime);
8780        let r = h.router.clone();
8781        let sender = h.invalidate_sender();
8782        runtime.block_on(async {
8783            let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8784            assert_eq!(status, StatusCode::OK);
8785            // Discard the init event.
8786            let init = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8787                .await
8788                .unwrap();
8789            assert_eq!(init.event, "init");
8790            // Fire a writer-actor-style event on the broadcast.
8791            sender
8792                .send(InvalidateEvent {
8793                    reason: "memory.remember".to_string(),
8794                    tenant_id: "default".to_string(),
8795                    ts_ms: 1_715_625_600_000,
8796                    kind: "episode".to_string(),
8797                })
8798                .expect("must have at least one subscriber");
8799            // The SSE handler must surface it.
8800            let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8801                .await
8802                .expect("invalidate event must arrive within 2s");
8803            assert_eq!(ev.event, "invalidate");
8804            assert_eq!(ev.data["reason"].as_str(), Some("memory.remember"));
8805            assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
8806            assert_eq!(ev.data["kind"].as_str(), Some("episode"));
8807        });
8808        h.shutdown(&runtime);
8809    }
8810
8811    /// 3. Each kind of writer-actor event surfaces with its mapped
8812    /// `(reason, kind)` shape.
8813    #[test]
8814    fn stream_emits_invalidate_for_each_writer_command() {
8815        let runtime = rt();
8816        let h = Harness::new(&runtime);
8817        let r = h.router.clone();
8818        let sender = h.invalidate_sender();
8819        let cases = [
8820            ("memory.remember", "episode"),
8821            ("memory.forget", "episode"),
8822            ("memory.consolidate", "cluster"),
8823            ("memory.ingest_document", "document"),
8824            ("memory.forget_document", "document"),
8825            ("memory.triples_extract", "cluster"),
8826            ("memory.reembed", "episode"),
8827            ("gdpr.forget_user", "tenant"),
8828        ];
8829        runtime.block_on(async {
8830            let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8831            assert_eq!(status, StatusCode::OK);
8832            // Discard the init.
8833            let _ = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8834                .await
8835                .unwrap();
8836            for (reason, kind) in cases {
8837                sender
8838                    .send(InvalidateEvent {
8839                        reason: reason.to_string(),
8840                        tenant_id: "default".to_string(),
8841                        ts_ms: 1_715_625_600_000,
8842                        kind: kind.to_string(),
8843                    })
8844                    .unwrap();
8845                let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8846                    .await
8847                    .unwrap_or_else(|| panic!("must receive event for {reason}"));
8848                assert_eq!(ev.event, "invalidate");
8849                assert_eq!(
8850                    ev.data["reason"].as_str(),
8851                    Some(reason),
8852                    "reason mismatch"
8853                );
8854                assert_eq!(ev.data["kind"].as_str(), Some(kind), "kind mismatch");
8855            }
8856        });
8857        h.shutdown(&runtime);
8858    }
8859
8860    /// 4. Heartbeat events fire on the configured interval when no real
8861    /// events arrive. Drives `build_invalidate_stream` at a 1-second
8862    /// heartbeat (the public handler uses 30s in prod), wraps it in an
8863    /// `Sse` response, then reads + parses the SSE body via the same
8864    /// `read_one_sse_event` helper the HTTP-layer tests use. This
8865    /// exercises the public Event → body byte path without touching
8866    /// `Event::finalize` (which is private).
8867    #[test]
8868    fn stream_emits_heartbeat_when_no_events() {
8869        let runtime = rt();
8870        let h = Harness::new(&runtime);
8871        let sender = h.invalidate_sender();
8872        runtime.block_on(async {
8873            // Subscribe FIRST so a later writer-side `send` would lag
8874            // the receiver if the subscriber stalled.
8875            let rx = sender.subscribe();
8876            // Build the SSE stream with a 1-second heartbeat interval —
8877            // bypassing the 30s production default.
8878            let stream = build_invalidate_stream(rx, "default".to_string(), 1);
8879            // Wrap in an Sse response + extract the body bytes through
8880            // axum's IntoResponse path. This produces real on-the-wire
8881            // SSE bytes that `read_one_sse_event` can parse.
8882            let sse: Sse<_> = Sse::new(stream);
8883            let resp = sse.into_response();
8884            let mut body = resp.into_body();
8885            // First event must be `init`.
8886            let first =
8887                read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8888                    .await
8889                    .expect("init event must arrive");
8890            assert_eq!(first.event, "init");
8891            // Second must be heartbeat (no invalidates fired, ~1s
8892            // interval; allow 3s window for runtime jitter).
8893            let second =
8894                read_one_sse_event(&mut body, std::time::Duration::from_secs(3))
8895                    .await
8896                    .expect("heartbeat event must arrive within 3s");
8897            assert_eq!(second.event, "heartbeat");
8898            assert!(second.data["ts_ms"].is_number());
8899        });
8900        h.shutdown(&runtime);
8901    }
8902
8903    /// 5. Two subscribers connected to the same tenant both receive
8904    /// every invalidate.
8905    #[test]
8906    fn stream_concurrent_subscribers_same_tenant() {
8907        let runtime = rt();
8908        let h = Harness::new(&runtime);
8909        let r1 = h.router.clone();
8910        let r2 = h.router.clone();
8911        let r3 = h.router.clone();
8912        let sender = h.invalidate_sender();
8913        runtime.block_on(async {
8914            // Open three subscribers.
8915            let (s1, mut body1) = open_sse_stream_inner(r1, None, None).await;
8916            let (s2, mut body2) = open_sse_stream_inner(r2, None, None).await;
8917            let (s3, mut body3) = open_sse_stream_inner(r3, None, None).await;
8918            assert_eq!(s1, StatusCode::OK);
8919            assert_eq!(s2, StatusCode::OK);
8920            assert_eq!(s3, StatusCode::OK);
8921            // Drain init events from each.
8922            for body in [&mut body1, &mut body2, &mut body3] {
8923                let ev = read_one_sse_event(body, std::time::Duration::from_secs(2))
8924                    .await
8925                    .unwrap();
8926                assert_eq!(ev.event, "init");
8927            }
8928            // Receiver count should be at least 3 now.
8929            assert!(
8930                sender.receiver_count() >= 3,
8931                "expected ≥3 subscribers, got {}",
8932                sender.receiver_count()
8933            );
8934            // Fire one invalidate.
8935            sender
8936                .send(InvalidateEvent {
8937                    reason: "memory.remember".to_string(),
8938                    tenant_id: "default".to_string(),
8939                    ts_ms: 1_715_625_600_000,
8940                    kind: "episode".to_string(),
8941                })
8942                .expect("send must succeed");
8943            // All three receive it.
8944            for body in [&mut body1, &mut body2, &mut body3] {
8945                let ev = read_one_sse_event(body, std::time::Duration::from_secs(2))
8946                    .await
8947                    .unwrap();
8948                assert_eq!(ev.event, "invalidate");
8949                assert_eq!(ev.data["reason"].as_str(), Some("memory.remember"));
8950            }
8951        });
8952        h.shutdown(&runtime);
8953    }
8954
8955    /// 6. Dropping the SSE client decrements the per-tenant subscriber
8956    /// count — graceful cleanup invariant.
8957    #[test]
8958    fn stream_handles_client_disconnect_gracefully() {
8959        let runtime = rt();
8960        let h = Harness::new(&runtime);
8961        let r = h.router.clone();
8962        let sender = h.invalidate_sender();
8963        let before = sender.receiver_count();
8964        runtime.block_on(async {
8965            let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8966            assert_eq!(status, StatusCode::OK);
8967            // Drain the init so the stream is fully active.
8968            let _ = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8969                .await
8970                .unwrap();
8971            let during = sender.receiver_count();
8972            assert!(
8973                during > before,
8974                "subscriber count must increase while stream is live (before={before}, during={during})"
8975            );
8976            // Drop the body — simulates the client closing the
8977            // connection. axum drops the stream future, which drops the
8978            // Receiver.
8979            drop(body);
8980        });
8981        // Allow tokio a beat to drop the Receiver task.
8982        runtime.block_on(async {
8983            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8984        });
8985        let after = sender.receiver_count();
8986        assert!(
8987            after <= before,
8988            "subscriber count must drop back after disconnect (before={before}, after={after})"
8989        );
8990        h.shutdown(&runtime);
8991    }
8992
8993    /// 7. Bearer-auth gate: missing token -> 401.
8994    #[test]
8995    fn stream_respects_auth_when_enabled() {
8996        let runtime = rt();
8997        let h = Harness::new_with_auth(&runtime, Some("stream-secret".into()));
8998        let r = h.router.clone();
8999        runtime.block_on(async {
9000            let (status, _body) = open_sse_stream_inner(r, None, None).await;
9001            assert_eq!(status, StatusCode::UNAUTHORIZED);
9002        });
9003        h.shutdown(&runtime);
9004    }
9005
9006    /// 8. Anonymous OK when auth=None (loopback default).
9007    #[test]
9008    fn stream_works_with_auth_none() {
9009        let runtime = rt();
9010        let h = Harness::new(&runtime);
9011        let r = h.router.clone();
9012        runtime.block_on(async {
9013            let (status, mut body) = open_sse_stream_inner(r, None, None).await;
9014            assert_eq!(status, StatusCode::OK);
9015            let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9016                .await
9017                .expect("must receive init event");
9018            assert_eq!(ev.event, "init");
9019        });
9020        h.shutdown(&runtime);
9021    }
9022
9023    /// 9. Bearer-auth gate: valid token allows the stream to open.
9024    #[test]
9025    fn stream_respects_auth_accepts_valid_token() {
9026        let runtime = rt();
9027        let h = Harness::new_with_auth(&runtime, Some("stream-secret".into()));
9028        let r = h.router.clone();
9029        runtime.block_on(async {
9030            let (status, mut body) =
9031                open_sse_stream_inner(r, Some("Bearer stream-secret"), None).await;
9032            assert_eq!(status, StatusCode::OK);
9033            let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9034                .await
9035                .expect("must receive init event with valid bearer");
9036            assert_eq!(ev.event, "init");
9037            assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
9038        });
9039        h.shutdown(&runtime);
9040    }
9041
9042    /// 10. Cross-tenant lookups are 404 at TenantExtractor before the
9043    /// stream opens — wrong tenant header never reaches the handler.
9044    #[test]
9045    fn stream_respects_tenant_scoping() {
9046        let runtime = rt();
9047        let h = Harness::new(&runtime);
9048        let r = h.router.clone();
9049        runtime.block_on(async {
9050            let (status, _body) =
9051                open_sse_stream_inner(r, None, Some("never-registered-tenant-x")).await;
9052            // The single-tenant test registry returns NotFound from
9053            // get_or_open when the header points to a tenant that isn't
9054            // cached; the TenantExtractor maps that to 404.
9055            assert_eq!(status, StatusCode::NOT_FOUND);
9056        });
9057        h.shutdown(&runtime);
9058    }
9059
9060    // -----------------------------------------------------------------
9061    // /v1/tenants — principal-scoped tenant list (v0.10.0)
9062    //
9063    // Seeds the harness's in-memory tenants_index stub via
9064    // `harness.registry.with_index(|idx| idx.register(...))` to drive
9065    // the read-only list endpoint. The default tenant from the
9066    // harness's HashMap is NOT in the index stub by construction (the
9067    // `for_tests_with_single_tenant` factory only wires the cached
9068    // HashMap entry; the index starts empty after migrations), so each
9069    // test that wants the default tenant listed registers it
9070    // explicitly. This keeps the test setup explicit about what's
9071    // visible to `list_active` versus what's open in memory.
9072    // -----------------------------------------------------------------
9073
9074    /// Seed three Active tenants into the registry's index. Returns the
9075    /// ids in the order they were registered, which is the order
9076    /// `list_active` will return them in (ORDER BY created_at_ms ASC).
9077    async fn seed_three_tenants(registry: &TenantRegistry) -> Vec<String> {
9078        use solo_core::TenantId as TenantIdT;
9079        let ids = ["alice", "bob", "default"];
9080        for id in ids {
9081            let tid = TenantIdT::new(id).unwrap();
9082            registry
9083                .with_index(|idx| {
9084                    idx.register(&tid, &format!("{id}.db"), Some(&format!("{id} tenant")))
9085                        .unwrap();
9086                    // Ensure created_at_ms diverges so the ASC sort is
9087                    // deterministic — the index uses `chrono::Utc::now()`
9088                    // per row and 3 sequential inserts can land in the
9089                    // same ms on fast hardware.
9090                })
9091                .await;
9092            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
9093        }
9094        // Sort matches the `created_at_ms ASC, tenant_id ASC` order
9095        // `TenantsIndex::list` returns. We inserted in (alice, bob,
9096        // default) order with 2ms gaps, so that's the expected order.
9097        vec!["alice".into(), "bob".into(), "default".into()]
9098    }
9099
9100    /// 1. With `AuthConfig::None`, the handler returns every tenant
9101    ///    visible in the registry — same scope as `solo tenants list`.
9102    ///    Exercises the "no principal" branch of the visibility filter.
9103    #[test]
9104    fn tenants_returns_all_when_auth_none() {
9105        let runtime = rt();
9106        let h = Harness::new(&runtime);
9107        let r = h.router.clone();
9108        runtime.block_on(async {
9109            let _expected = seed_three_tenants(&h.registry).await;
9110            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9111            assert_eq!(status, StatusCode::OK);
9112            let arr = body
9113                .get("tenants")
9114                .and_then(|v| v.as_array())
9115                .expect("tenants array");
9116            assert_eq!(arr.len(), 3, "got body: {body}");
9117            let ids: Vec<&str> =
9118                arr.iter().filter_map(|t| t["id"].as_str()).collect();
9119            assert_eq!(ids, vec!["alice", "bob", "default"]);
9120        });
9121        h.shutdown(&runtime);
9122    }
9123
9124    /// 2. Under Bearer auth (single-principal mode), the handler
9125    ///    returns every tenant — the bearer holder is treated as the
9126    ///    daemon operator with full visibility. Exercises the bearer
9127    ///    branch of the visibility filter.
9128    #[test]
9129    fn tenants_returns_all_when_bearer_auth() {
9130        let runtime = rt();
9131        let h = Harness::new_with_auth(&runtime, Some("tlist-secret".into()));
9132        let r = h.router.clone();
9133        runtime.block_on(async {
9134            seed_three_tenants(&h.registry).await;
9135            let (status, body) = call_with_auth(
9136                r,
9137                "GET",
9138                "/v1/tenants",
9139                None,
9140                Some("Bearer tlist-secret"),
9141            )
9142            .await;
9143            assert_eq!(status, StatusCode::OK, "got body: {body}");
9144            let arr = body["tenants"].as_array().expect("tenants array");
9145            assert_eq!(arr.len(), 3, "bearer must see all tenants");
9146        });
9147        h.shutdown(&runtime);
9148    }
9149
9150    /// 3. Under OIDC, an authenticated principal carrying
9151    ///    `tenant_claim = "alice"` sees ONLY alice — not bob, not
9152    ///    default. Exercises the OIDC branch of the visibility filter.
9153    #[test]
9154    fn tenants_filters_to_principal_claim_when_oidc() {
9155        let runtime = rt();
9156        let (fake_server, discovery_url, secret, kid) =
9157            runtime.block_on(async { spin_fake_idp().await });
9158        let server_uri = fake_server.uri();
9159        let _server_guard = fake_server;
9160
9161        let auth = crate::auth::AuthConfig::Oidc {
9162            discovery_url,
9163            audience: "tlist-audience".to_string(),
9164            tenant_claim_name: "solo_tenant".to_string(),
9165        };
9166        let h = Harness::new_with_auth_config(&runtime, Some(auth));
9167        let r = h.router.clone();
9168
9169        runtime.block_on(async {
9170            seed_three_tenants(&h.registry).await;
9171            let token = mint_idp_token(
9172                &server_uri,
9173                kid,
9174                &secret,
9175                "alice",
9176                "tlist-audience",
9177            );
9178            let (status, body) = call_with_auth(
9179                r,
9180                "GET",
9181                "/v1/tenants",
9182                None,
9183                Some(&format!("Bearer {token}")),
9184            )
9185            .await;
9186            assert_eq!(status, StatusCode::OK, "got body: {body}");
9187            let arr = body["tenants"].as_array().expect("tenants array");
9188            assert_eq!(arr.len(), 1, "OIDC alice must see exactly one tenant");
9189            assert_eq!(arr[0]["id"].as_str(), Some("alice"));
9190        });
9191        h.shutdown(&runtime);
9192    }
9193
9194    /// 4. Under OIDC with a `tenant_claim` that doesn't match any
9195    ///    registered tenant, the response is `200 OK` with
9196    ///    `tenants: []` — NOT 404. Don't leak whether other tenants
9197    ///    exist via a status-code side-channel for an OIDC principal
9198    ///    that lacks visibility to them.
9199    #[test]
9200    fn tenants_returns_empty_when_oidc_claim_unmatched() {
9201        let runtime = rt();
9202        let (fake_server, discovery_url, secret, kid) =
9203            runtime.block_on(async { spin_fake_idp().await });
9204        let server_uri = fake_server.uri();
9205        let _server_guard = fake_server;
9206
9207        let auth = crate::auth::AuthConfig::Oidc {
9208            discovery_url,
9209            audience: "tlist-audience".to_string(),
9210            tenant_claim_name: "solo_tenant".to_string(),
9211        };
9212        let h = Harness::new_with_auth_config(&runtime, Some(auth));
9213        let r = h.router.clone();
9214
9215        runtime.block_on(async {
9216            seed_three_tenants(&h.registry).await;
9217            // Mint a token claiming a tenant that IS a valid TenantId
9218            // (passes middleware) but doesn't exist in the index.
9219            let token = mint_idp_token(
9220                &server_uri,
9221                kid,
9222                &secret,
9223                "nonexistent",
9224                "tlist-audience",
9225            );
9226            let (status, body) = call_with_auth(
9227                r,
9228                "GET",
9229                "/v1/tenants",
9230                None,
9231                Some(&format!("Bearer {token}")),
9232            )
9233            .await;
9234            assert_eq!(
9235                status,
9236                StatusCode::OK,
9237                "must be 200 OK, not 404 — don't leak tenant existence: {body}"
9238            );
9239            let arr = body["tenants"].as_array().expect("tenants array");
9240            assert_eq!(
9241                arr.len(),
9242                0,
9243                "unmatched OIDC claim must produce empty list, got: {body}"
9244            );
9245        });
9246        h.shutdown(&runtime);
9247    }
9248
9249    /// 5. JSON response shape matches what solo-web's TypeScript
9250    ///    client expects: `tenants[*].{id,display_name,created_at_ms,
9251    ///    status,quota_bytes,episode_count,size_bytes,pct_used,
9252    ///    last_accessed_ms}`. Catches accidental field renames at PR
9253    ///    time.
9254    ///
9255    ///    v0.10.1: `episode_count` / `size_bytes` / `pct_used` are
9256    ///    hydrated when the per-tenant DB file exists. This test
9257    ///    registers a tenant whose DB file does NOT exist (the
9258    ///    `for_tests_with_single_tenant` harness only writes the
9259    ///    `default` tenant's DB), so the three numeric fields land as
9260    ///    JSON `null` — verifying the `null` JSON value (not absence)
9261    ///    so clients see a stable shape regardless of hydration
9262    ///    success.
9263    #[test]
9264    fn tenants_response_shape_matches_solo_web_types() {
9265        let runtime = rt();
9266        let h = Harness::new(&runtime);
9267        let r = h.router.clone();
9268        runtime.block_on(async {
9269            // Register one tenant with a display_name + quota so all
9270            // optional fields are present in the response.
9271            let tid = solo_core::TenantId::new("shaped").unwrap();
9272            h.registry
9273                .with_index(|idx| {
9274                    idx.register_with_quota(
9275                        &tid,
9276                        "shaped.db",
9277                        Some("Shaped tenant"),
9278                        Some(1_048_576),
9279                    )
9280                    .unwrap();
9281                })
9282                .await;
9283            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9284            assert_eq!(status, StatusCode::OK);
9285            let item = &body["tenants"][0];
9286            // id, display_name, created_at_ms, status: required
9287            assert_eq!(item["id"].as_str(), Some("shaped"));
9288            assert_eq!(item["display_name"].as_str(), Some("Shaped tenant"));
9289            assert!(
9290                item["created_at_ms"].is_i64(),
9291                "created_at_ms must be an i64, got {item}"
9292            );
9293            assert_eq!(item["status"].as_str(), Some("active"));
9294            // quota_bytes: present + numeric
9295            assert_eq!(item["quota_bytes"].as_u64(), Some(1_048_576));
9296            // v0.10.1: episode_count / size_bytes / pct_used become
9297            // null when the per-tenant DB file is missing on disk
9298            // (this harness only writes the default tenant's file —
9299            // shaped.db does not exist). Clients must tolerate the
9300            // null JSON shape; absence would be a breaking change.
9301            assert!(
9302                item["episode_count"].is_null(),
9303                "episode_count must be JSON null when tenant DB is missing, got {item}"
9304            );
9305            assert!(
9306                item["size_bytes"].is_null(),
9307                "size_bytes must be JSON null when tenant DB is missing, got {item}"
9308            );
9309            assert!(
9310                item["pct_used"].is_null(),
9311                "pct_used must be JSON null when size_bytes is null, got {item}"
9312            );
9313        });
9314        h.shutdown(&runtime);
9315    }
9316
9317    /// 6. Bearer auth enabled + missing Authorization header → 401
9318    ///    before the handler runs. Confirms the route is plumbed
9319    ///    through `auth_middleware` (it sits inside the `authed`
9320    ///    sub-router, not the `public` one).
9321    #[test]
9322    fn tenants_respects_auth_when_enabled() {
9323        let runtime = rt();
9324        let h = Harness::new_with_auth(&runtime, Some("must-auth".into()));
9325        let r = h.router.clone();
9326        runtime.block_on(async {
9327            seed_three_tenants(&h.registry).await;
9328            // No Authorization header → 401.
9329            let (status, _body) = call(r, "GET", "/v1/tenants", None).await;
9330            assert_eq!(status, StatusCode::UNAUTHORIZED);
9331        });
9332        h.shutdown(&runtime);
9333    }
9334
9335    /// 7. `PendingMigration` and `PendingDelete` rows are excluded
9336    ///    from the response. solo-web's tenant picker should never
9337    ///    surface a row that's mid-admin-operation (race with admin
9338    ///    tooling). Only Active tenants make the list.
9339    #[test]
9340    fn tenants_status_filter_excludes_non_active() {
9341        let runtime = rt();
9342        let h = Harness::new(&runtime);
9343        let r = h.router.clone();
9344        runtime.block_on(async {
9345            // Three tenants, three statuses. Only `keeper` (Active)
9346            // should appear on the wire.
9347            let keeper = solo_core::TenantId::new("keeper").unwrap();
9348            let migrating = solo_core::TenantId::new("migrating").unwrap();
9349            let deleting = solo_core::TenantId::new("deleting").unwrap();
9350            h.registry
9351                .with_index(|idx| {
9352                    idx.register(&keeper, "keeper.db", None).unwrap();
9353                    idx.register_with_status(
9354                        &migrating,
9355                        "migrating.db",
9356                        None,
9357                        solo_storage::TenantStatus::PendingMigration,
9358                    )
9359                    .unwrap();
9360                    idx.register_with_status(
9361                        &deleting,
9362                        "deleting.db",
9363                        None,
9364                        solo_storage::TenantStatus::PendingDelete,
9365                    )
9366                    .unwrap();
9367                })
9368                .await;
9369            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9370            assert_eq!(status, StatusCode::OK);
9371            let arr = body["tenants"].as_array().expect("tenants array");
9372            let ids: Vec<&str> =
9373                arr.iter().filter_map(|t| t["id"].as_str()).collect();
9374            assert_eq!(
9375                ids,
9376                vec!["keeper"],
9377                "only Active tenants visible; got: {body}"
9378            );
9379        });
9380        h.shutdown(&runtime);
9381    }
9382
9383    /// 8. Empty registry → `200 OK` with `tenants: []`. Defends
9384    ///    against accidental `None` serialisation or 404'ing on an
9385    ///    empty list. solo-web's first paint on a brand-new daemon
9386    ///    needs an empty array to render the "no tenants yet" state.
9387    #[test]
9388    fn tenants_returns_empty_array_when_no_tenants_registered() {
9389        let runtime = rt();
9390        let h = Harness::new(&runtime);
9391        let r = h.router.clone();
9392        runtime.block_on(async {
9393            // Don't seed anything — the harness's in-memory index
9394            // starts at zero rows (the cached default-tenant handle in
9395            // the HashMap is invisible to `list_active`).
9396            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9397            assert_eq!(status, StatusCode::OK);
9398            let arr = body["tenants"].as_array().expect("tenants array");
9399            assert_eq!(arr.len(), 0, "expected empty array, got: {body}");
9400        });
9401        h.shutdown(&runtime);
9402    }
9403
9404    // ---- v0.10.1: cost-number hydration tests ----
9405    //
9406    // These exercise `TenantRegistry::hydrate_tenant_cost_numbers` end-
9407    // to-end through the `/v1/tenants` handler. The harness's
9408    // `for_tests_with_single_tenant` registry uses a plain-SQLite tenant
9409    // DB (not real SQLCipher); the hydration helper has a fallback
9410    // open path for that case (see registry.rs). The
9411    // `_tmp_dir/tenants/<filename>` layout matters: that's where the
9412    // hydration helper looks. These tests create real files there to
9413    // exercise the size_bytes path; episode_count requires the file to
9414    // be a SQLite DB with the `episodes` table.
9415    //
9416    // The `default` tenant exists at `_tmp_dir/test.db` (set by the
9417    // harness); the hydration helper expects `_tmp_dir/tenants/<file>`.
9418    // So we either (a) register a fresh tenant id pointing at a DB we
9419    // create at the expected layout, or (b) check the documented
9420    // behavior under "file missing" (returns null counts gracefully).
9421    // Both shapes are tested here.
9422    //
9423    // The constant `TENANTS_COUNT_HYDRATION_CAP` is grep-able.
9424
9425    /// Helper: create a per-tenant DB file at the layout the hydration
9426    /// helper expects (`<data_dir>/tenants/<db_filename>`), populated
9427    /// with the `episodes` table + `n_active` active episodes +
9428    /// `n_forgotten` forgotten episodes. Returns the absolute path.
9429    fn seed_per_tenant_db_with_episodes(
9430        data_dir: &std::path::Path,
9431        db_filename: &str,
9432        n_active: i64,
9433        n_forgotten: i64,
9434    ) -> std::path::PathBuf {
9435        let tenants_dir = data_dir.join(solo_storage::TENANTS_SUBDIR);
9436        std::fs::create_dir_all(&tenants_dir).unwrap();
9437        let db_path = tenants_dir.join(db_filename);
9438        // Open as plain SQLite (test path; matches the harness's
9439        // `open_test_db_at` shape; hydration helper falls back to plain
9440        // open when SQLCipher open fails).
9441        let mut conn = rusqlite::Connection::open(&db_path).unwrap();
9442        // Run the same migrations the real per-tenant DB does so the
9443        // `episodes` table + `status` CHECK constraint match production.
9444        solo_storage::run_migrations(&mut conn).unwrap();
9445        for i in 0..n_active {
9446            conn.execute(
9447                "INSERT INTO episodes (memory_id, ts_ms, source_type, content, confidence, strength, salience, tier, status, created_at_ms, updated_at_ms)
9448                 VALUES (?, 0, 'user_message', 'x', 0.5, 0.5, 0.5, 'hot', 'active', 0, 0)",
9449                rusqlite::params![format!("a-{i}")],
9450            )
9451            .unwrap();
9452        }
9453        for i in 0..n_forgotten {
9454            conn.execute(
9455                "INSERT INTO episodes (memory_id, ts_ms, source_type, content, confidence, strength, salience, tier, status, created_at_ms, updated_at_ms)
9456                 VALUES (?, 0, 'user_message', 'x', 0.5, 0.5, 0.5, 'hot', 'forgotten', 0, 0)",
9457                rusqlite::params![format!("f-{i}")],
9458            )
9459            .unwrap();
9460        }
9461        drop(conn);
9462        db_path
9463    }
9464
9465    /// v0.10.1 test 1: `episode_count` hydrates to the actual active
9466    /// episode count when the per-tenant DB exists. Seed 3 active + 2
9467    /// forgotten episodes; expect `episode_count: 3` (the `status =
9468    /// 'active'` filter excludes the forgotten rows).
9469    #[test]
9470    fn tenants_response_hydrates_episode_count_when_tenant_has_data() {
9471        let runtime = rt();
9472        let h = Harness::new(&runtime);
9473        let r = h.router.clone();
9474        let data_dir = h._tmp.path().to_path_buf();
9475        runtime.block_on(async {
9476            let tid = solo_core::TenantId::new("counted").unwrap();
9477            seed_per_tenant_db_with_episodes(&data_dir, "counted.db", 3, 2);
9478            h.registry
9479                .with_index(|idx| {
9480                    idx.register(&tid, "counted.db", Some("Counted tenant"))
9481                        .unwrap();
9482                })
9483                .await;
9484            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9485            assert_eq!(status, StatusCode::OK);
9486            let item = &body["tenants"][0];
9487            assert_eq!(item["id"].as_str(), Some("counted"));
9488            assert_eq!(
9489                item["episode_count"].as_i64(),
9490                Some(3),
9491                "episode_count must be 3 (active rows only, 2 forgotten excluded); got {item}"
9492            );
9493        });
9494        h.shutdown(&runtime);
9495    }
9496
9497    /// v0.10.1 test 2: `size_bytes` reports the on-disk size of the
9498    /// per-tenant DB file. Asserts the response value matches
9499    /// `std::fs::metadata(<db_path>).len()` exactly — pins that we
9500    /// read the right file, not e.g. data_dir or a temp.
9501    #[test]
9502    fn tenants_response_hydrates_size_bytes_from_db_file() {
9503        let runtime = rt();
9504        let h = Harness::new(&runtime);
9505        let r = h.router.clone();
9506        let data_dir = h._tmp.path().to_path_buf();
9507        runtime.block_on(async {
9508            let tid = solo_core::TenantId::new("sized").unwrap();
9509            let db_path =
9510                seed_per_tenant_db_with_episodes(&data_dir, "sized.db", 1, 0);
9511            h.registry
9512                .with_index(|idx| {
9513                    idx.register(&tid, "sized.db", None).unwrap();
9514                })
9515                .await;
9516            let on_disk = std::fs::metadata(&db_path).unwrap().len();
9517            assert!(on_disk > 0, "test setup: db file should be non-empty");
9518            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9519            assert_eq!(status, StatusCode::OK);
9520            let item = &body["tenants"][0];
9521            assert_eq!(item["id"].as_str(), Some("sized"));
9522            assert_eq!(
9523                item["size_bytes"].as_u64(),
9524                Some(on_disk),
9525                "size_bytes must match fs::metadata; got {item}"
9526            );
9527        });
9528        h.shutdown(&runtime);
9529    }
9530
9531    /// v0.10.1 test 3: `pct_used` is computed from `size_bytes /
9532    /// quota_bytes * 100` when both are known. Pick a quota much
9533    /// larger than the DB so the percentage stays in a sane range
9534    /// (and survives any unrelated DB-page padding).
9535    #[test]
9536    fn tenants_response_computes_pct_used_when_quota_set() {
9537        let runtime = rt();
9538        let h = Harness::new(&runtime);
9539        let r = h.router.clone();
9540        let data_dir = h._tmp.path().to_path_buf();
9541        runtime.block_on(async {
9542            let tid = solo_core::TenantId::new("quoted").unwrap();
9543            let db_path =
9544                seed_per_tenant_db_with_episodes(&data_dir, "quoted.db", 1, 0);
9545            // Pick a quota that's large enough that pct_used lands
9546            // between 0 and 50% regardless of SQLite page boundary
9547            // rounding. Asserting an exact float would be flaky.
9548            let on_disk = std::fs::metadata(&db_path).unwrap().len();
9549            let quota = on_disk * 4; // pct_used should be ~25%
9550            h.registry
9551                .with_index(|idx| {
9552                    idx.register_with_quota(&tid, "quoted.db", None, Some(quota))
9553                        .unwrap();
9554                })
9555                .await;
9556            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9557            assert_eq!(status, StatusCode::OK);
9558            let item = &body["tenants"][0];
9559            let pct = item["pct_used"].as_f64().expect("pct_used must be a number");
9560            assert!(
9561                (0.0..=100.0).contains(&pct),
9562                "pct_used must be in [0, 100], got {pct}"
9563            );
9564            // Allow a wide band — exact value depends on SQLite page
9565            // size — but the recipe (size/quota*100) means a
9566            // size=quota/4 setup must land near 25%.
9567            assert!(
9568                (20.0..=30.0).contains(&pct),
9569                "pct_used must be ~25% for size=quota/4, got {pct}"
9570            );
9571        });
9572        h.shutdown(&runtime);
9573    }
9574
9575    /// v0.10.1 test 4: `pct_used` is `null` when `quota_bytes` is
9576    /// null (the "unlimited" case). Pins that we don't accidentally
9577    /// emit a numeric `0.0` or `100.0` for unlimited quotas.
9578    #[test]
9579    fn tenants_response_pct_used_null_when_quota_null() {
9580        let runtime = rt();
9581        let h = Harness::new(&runtime);
9582        let r = h.router.clone();
9583        let data_dir = h._tmp.path().to_path_buf();
9584        runtime.block_on(async {
9585            let tid = solo_core::TenantId::new("unlimited").unwrap();
9586            seed_per_tenant_db_with_episodes(&data_dir, "unlimited.db", 1, 0);
9587            h.registry
9588                .with_index(|idx| {
9589                    idx.register(&tid, "unlimited.db", None).unwrap();
9590                })
9591                .await;
9592            let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9593            assert_eq!(status, StatusCode::OK);
9594            let item = &body["tenants"][0];
9595            assert_eq!(item["id"].as_str(), Some("unlimited"));
9596            assert!(
9597                item["quota_bytes"].is_null(),
9598                "test setup: quota_bytes must be null, got {item}"
9599            );
9600            assert!(
9601                item["pct_used"].is_null(),
9602                "pct_used must be JSON null when quota_bytes is null, got {item}"
9603            );
9604            // size_bytes still present (no quota doesn't suppress
9605            // size — only pct_used).
9606            assert!(
9607                item["size_bytes"].is_u64(),
9608                "size_bytes must still be present when quota_bytes is null, got {item}"
9609            );
9610        });
9611        h.shutdown(&runtime);
9612    }
9613
9614    /// v0.10.1 test 5: the response includes
9615    /// `X-Solo-Tenants-Count-Cap-Reached: true` when the filtered
9616    /// tenant count exceeds `TENANTS_COUNT_HYDRATION_CAP`. Tenants
9617    /// beyond the cap have `episode_count: null` even though their
9618    /// `size_bytes` is still hydrated (fs::metadata is cheap).
9619    ///
9620    /// We don't seed 51 real DBs (would be slow); instead, we
9621    /// register 51 tenant rows in the index. The cap is documented
9622    /// to apply to `episode_count` hydration, and the header is
9623    /// emitted purely from the count of filtered records. The
9624    /// header semantics here are independent of per-tenant DB
9625    /// existence.
9626    #[test]
9627    fn tenants_response_sets_cap_reached_header_when_over_cap() {
9628        let runtime = rt();
9629        let h = Harness::new(&runtime);
9630        let r = h.router.clone();
9631        runtime.block_on(async {
9632            // Register 51 tenants (cap = 50, so we exceed it).
9633            h.registry
9634                .with_index(|idx| {
9635                    for i in 0..51 {
9636                        let id = format!("t{i:02}");
9637                        let tid = solo_core::TenantId::new(&id).unwrap();
9638                        idx.register(&tid, &format!("{id}.db"), None).unwrap();
9639                    }
9640                })
9641                .await;
9642            // Send a raw request so we can inspect headers.
9643            use axum::body::Body;
9644            use axum::http::Request;
9645            use http_body_util::BodyExt;
9646            let req = Request::builder()
9647                .method("GET")
9648                .uri("/v1/tenants")
9649                .body(Body::empty())
9650                .unwrap();
9651            let resp = r.oneshot(req).await.unwrap();
9652            assert_eq!(resp.status(), StatusCode::OK);
9653            let cap_header = resp
9654                .headers()
9655                .get(X_SOLO_TENANTS_COUNT_CAP_HEADER)
9656                .expect("cap-reached header must be present");
9657            assert_eq!(
9658                cap_header.to_str().unwrap(),
9659                "true",
9660                "cap-reached header value must be 'true' when over cap"
9661            );
9662            // Parse body to verify shape — beyond-cap tenants have
9663            // null episode_count.
9664            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
9665            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9666            let arr = body["tenants"].as_array().expect("tenants array");
9667            assert_eq!(arr.len(), 51, "got {} tenants", arr.len());
9668            // The last (sorted-by-created_at_ms) tenant should be
9669            // beyond the cap. The hydration order matches the
9670            // filtered list order, so index 50 is the 51st tenant
9671            // and should have null episode_count.
9672            assert!(
9673                arr[50]["episode_count"].is_null(),
9674                "the 51st tenant (beyond cap) must have null episode_count, got {}",
9675                arr[50]
9676            );
9677        });
9678        h.shutdown(&runtime);
9679    }
9680
9681    /// v0.10.1 test 6: when the response is under the cap, the
9682    /// `X-Solo-Tenants-Count-Cap-Reached` header is absent. Pin the
9683    /// negative case so a future refactor that always emits the
9684    /// header (with "false") doesn't pass silently.
9685    #[test]
9686    fn tenants_response_omits_cap_header_when_under_cap() {
9687        let runtime = rt();
9688        let h = Harness::new(&runtime);
9689        let r = h.router.clone();
9690        runtime.block_on(async {
9691            seed_three_tenants(&h.registry).await;
9692            use axum::body::Body;
9693            use axum::http::Request;
9694            let req = Request::builder()
9695                .method("GET")
9696                .uri("/v1/tenants")
9697                .body(Body::empty())
9698                .unwrap();
9699            let resp = r.oneshot(req).await.unwrap();
9700            assert_eq!(resp.status(), StatusCode::OK);
9701            assert!(
9702                resp.headers().get(X_SOLO_TENANTS_COUNT_CAP_HEADER).is_none(),
9703                "cap-reached header must be absent under the cap"
9704            );
9705        });
9706        h.shutdown(&runtime);
9707    }
9708
9709    // ---- Pure unit tests on the visibility filter ----
9710    //
9711    // These exercise `filter_tenants_for_principal` and
9712    // `is_single_principal_bearer` without an axum router — fast
9713    // feedback for the load-bearing visibility rule. The
9714    // router-level tests above cover the wire path.
9715
9716    /// Build a synthetic `TenantRecord` so the pure unit tests don't
9717    /// need a real SQLCipher round-trip.
9718    fn make_record(id: &str) -> solo_storage::TenantRecord {
9719        solo_storage::TenantRecord {
9720            tenant_id: solo_core::TenantId::new(id).unwrap(),
9721            db_filename: format!("{id}.db"),
9722            display_name: None,
9723            created_at_ms: 0,
9724            status: solo_storage::TenantStatus::Active,
9725            quota_bytes: None,
9726            last_accessed_ms: None,
9727        }
9728    }
9729
9730    #[test]
9731    fn filter_no_principal_returns_all() {
9732        let records = vec![make_record("a"), make_record("b")];
9733        let out = filter_tenants_for_principal(records.clone(), None);
9734        assert_eq!(out.len(), 2);
9735        assert_eq!(out[0].tenant_id.as_str(), "a");
9736        assert_eq!(out[1].tenant_id.as_str(), "b");
9737    }
9738
9739    #[test]
9740    fn filter_bearer_principal_returns_all() {
9741        let records = vec![make_record("a"), make_record("b")];
9742        let p = AuthenticatedPrincipal::bearer(
9743            solo_core::TenantId::new("a").unwrap(),
9744        );
9745        let out = filter_tenants_for_principal(records, Some(&p));
9746        assert_eq!(out.len(), 2);
9747    }
9748
9749    #[test]
9750    fn filter_oidc_principal_keeps_only_claim() {
9751        let records = vec![make_record("a"), make_record("b"), make_record("c")];
9752        // OIDC-flavoured principal: non-bearer subject + JSON-object claims.
9753        let p = AuthenticatedPrincipal {
9754            subject: "alice@example.com".to_string(),
9755            tenant_claim: Some(solo_core::TenantId::new("b").unwrap()),
9756            scopes: vec!["read".to_string()],
9757            claims: serde_json::json!({ "sub": "alice@example.com" }),
9758        };
9759        let out = filter_tenants_for_principal(records, Some(&p));
9760        assert_eq!(out.len(), 1);
9761        assert_eq!(out[0].tenant_id.as_str(), "b");
9762    }
9763
9764    #[test]
9765    fn filter_oidc_principal_with_no_claim_returns_empty() {
9766        // Theoretically unreachable — middleware short-circuits at 403
9767        // before we see a no-claim OIDC principal. Defend anyway.
9768        let records = vec![make_record("a")];
9769        let p = AuthenticatedPrincipal {
9770            subject: "alice@example.com".to_string(),
9771            tenant_claim: None,
9772            scopes: vec![],
9773            claims: serde_json::json!({ "sub": "alice@example.com" }),
9774        };
9775        let out = filter_tenants_for_principal(records, Some(&p));
9776        assert!(out.is_empty());
9777    }
9778
9779    #[test]
9780    fn is_single_principal_bearer_discriminator() {
9781        let bearer = AuthenticatedPrincipal::bearer(
9782            solo_core::TenantId::new("default").unwrap(),
9783        );
9784        assert!(is_single_principal_bearer(&bearer));
9785
9786        let oidc = AuthenticatedPrincipal {
9787            subject: "alice".to_string(),
9788            tenant_claim: Some(solo_core::TenantId::new("alice").unwrap()),
9789            scopes: vec![],
9790            claims: serde_json::json!({ "x": 1 }),
9791        };
9792        assert!(!is_single_principal_bearer(&oidc));
9793
9794        // Subject == "bearer" but claims is a non-null object → not a
9795        // bearer-shaped principal. Defends against a forged-bearer
9796        // shape that might smuggle JWT claims.
9797        let weird = AuthenticatedPrincipal {
9798            subject: "bearer".to_string(),
9799            tenant_claim: Some(solo_core::TenantId::default_tenant()),
9800            scopes: vec![],
9801            claims: serde_json::json!({ "leak": 1 }),
9802        };
9803        assert!(!is_single_principal_bearer(&weird));
9804    }
9805}
9806
9807#[cfg(test)]
9808mod cors_tests {
9809    use super::is_localhost_origin;
9810
9811    #[test]
9812    fn accepts_canonical_localhost_origins() {
9813        assert!(is_localhost_origin("http://localhost"));
9814        assert!(is_localhost_origin("http://localhost:3000"));
9815        assert!(is_localhost_origin("https://localhost:8443"));
9816        assert!(is_localhost_origin("http://127.0.0.1"));
9817        assert!(is_localhost_origin("http://127.0.0.1:5173"));
9818        assert!(is_localhost_origin("http://[::1]"));
9819        assert!(is_localhost_origin("http://[::1]:8080"));
9820    }
9821
9822    #[test]
9823    fn rejects_remote_origins() {
9824        assert!(!is_localhost_origin("http://example.com"));
9825        assert!(!is_localhost_origin("https://malicious.example"));
9826        assert!(!is_localhost_origin("http://192.168.1.5"));
9827        assert!(!is_localhost_origin("http://10.0.0.1"));
9828    }
9829
9830    #[test]
9831    fn rejects_dns_rebinding_tricks() {
9832        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
9833        // Origin header carries the public-DNS name. Rejecting these
9834        // closes the rebinding-via-Origin gap.
9835        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
9836        assert!(!is_localhost_origin("http://localhost.evil.com"));
9837        assert!(!is_localhost_origin("http://evil.localhost"));
9838    }
9839
9840    #[test]
9841    fn rejects_non_http_schemes() {
9842        assert!(!is_localhost_origin("file:///"));
9843        assert!(!is_localhost_origin("ws://localhost:3000"));
9844        assert!(!is_localhost_origin("javascript:alert(1)"));
9845    }
9846
9847    #[test]
9848    fn rejects_malformed() {
9849        assert!(!is_localhost_origin(""));
9850        assert!(!is_localhost_origin("localhost"));
9851        assert!(!is_localhost_origin("//localhost"));
9852    }
9853}
9854