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::net::SocketAddr;
43use std::str::FromStr;
44use std::sync::Arc;
45
46use axum::extract::{FromRequestParts, Path, Query, State};
47use axum::http::request::Parts;
48use axum::http::{HeaderValue, Method, StatusCode};
49use axum::response::{IntoResponse, Response};
50use axum::routing::{get, post};
51use axum::{Json, Router};
52use serde::{Deserialize, Serialize};
53use solo_core::{
54    Confidence, DocumentId, EncodingContext, Episode, MemoryId, TenantId, Tier,
55};
56use solo_storage::{TenantHandle, TenantRegistry};
57use tower_http::cors::{AllowOrigin, CorsLayer};
58use tower_http::trace::TraceLayer;
59
60use crate::auth::{AuthConfig, AuthenticatedPrincipal, middleware::AuthValidator};
61
62/// HTTP-side application state. v0.8.0 P2 swapped per-handler `WriteHandle
63/// + ReaderPool + ...` for a `TenantRegistry` that resolves tenant on each
64/// request via the `X-Solo-Tenant` header (default tenant if absent).
65#[derive(Clone)]
66pub struct SoloHttpState {
67    /// Multi-tenant registry. Lazy-loads tenants on first request.
68    pub registry: Arc<TenantRegistry>,
69    /// Default tenant used when the `X-Solo-Tenant` header is absent.
70    /// Typically `TenantId::default_tenant()`.
71    pub default_tenant: TenantId,
72    /// Read-path aliases for the canonical `"user"` subject. Sourced
73    /// from `solo.config.toml` `[identity] user_aliases`; threaded
74    /// through to `solo_query::facts_about` so a query for `"alex"`
75    /// also surfaces rows historically extracted as `"user"`. Empty
76    /// vec = behave as today. Wrapped in `Arc` so handler `clone()`s
77    /// stay cheap. v0.5.0 Priority 1 sub-step 1C.
78    pub user_aliases: Arc<Vec<String>>,
79}
80
81/// HTTP header that routes a request to a specific tenant. Optional;
82/// absent → state.default_tenant.
83pub const TENANT_HEADER: &str = "x-solo-tenant";
84
85/// Axum extractor that resolves the request's target tenant, then
86/// lazy-opens the tenant via the registry.
87///
88/// Resolution order (v0.8.0 P3):
89///   1. `AuthenticatedPrincipal.tenant_claim` from request extensions —
90///      set by the auth middleware. In OIDC mode this is the validated
91///      value of the configured custom claim (default `solo_tenant`);
92///      in bearer mode this is the daemon's default tenant.
93///   2. `X-Solo-Tenant` header — falls back to this when no
94///      authenticated principal is on the request (unauthenticated
95///      loopback deployments — the default).
96///   3. `state.default_tenant` when neither is present.
97///
98/// Bad header values → 400. Lazy-open failures → 500 unless the failure
99/// kind is `NotFound` (unknown tenant id) → 404.
100pub struct TenantExtractor(pub Arc<TenantHandle>);
101
102impl<S> FromRequestParts<S> for TenantExtractor
103where
104    SoloHttpState: FromRef<S>,
105    S: Send + Sync,
106{
107    type Rejection = ApiError;
108
109    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
110        let state = SoloHttpState::from_ref(state);
111        // Order: (1) principal.tenant_claim (set by auth middleware),
112        // (2) X-Solo-Tenant header, (3) state.default_tenant.
113        //
114        // The principal wins because in OIDC mode the JWT is the source
115        // of truth — letting the header override an OIDC claim would
116        // be a tenant-impersonation hole.
117        let resolved = if let Some(principal) = parts.extensions.get::<AuthenticatedPrincipal>()
118            && let Some(claim) = principal.tenant_claim.clone()
119        {
120            claim
121        } else {
122            match parts.headers.get(TENANT_HEADER) {
123                None => state.default_tenant.clone(),
124                Some(raw) => {
125                    let s = raw.to_str().map_err(|e| {
126                        ApiError::bad_request(format!(
127                            "{TENANT_HEADER}: header value must be ASCII ({e})"
128                        ))
129                    })?;
130                    TenantId::new(s.to_string()).map_err(|e| {
131                        ApiError::bad_request(format!("{TENANT_HEADER}: invalid tenant id: {e}"))
132                    })?
133                }
134            }
135        };
136        let handle = state.registry.get_or_open(&resolved).await.map_err(|e| {
137            // Map NotFound → 404; everything else → 500.
138            use solo_core::Error;
139            match &e {
140                Error::NotFound(_) => ApiError::not_found(e.to_string()),
141                Error::InvalidInput(_) => ApiError::bad_request(e.to_string()),
142                _ => ApiError::internal(e.to_string()),
143            }
144        })?;
145        Ok(TenantExtractor(handle))
146    }
147}
148
149use axum::extract::FromRef;
150
151/// v0.8.0 P4: extractor that pulls the authenticated principal's
152/// `subject` (JWT `sub` or `"bearer"`) out of request extensions for the
153/// audit log. `None` when no `AuthenticatedPrincipal` is present
154/// (unauthenticated loopback deployments).
155pub struct AuditPrincipal(pub Option<String>);
156
157impl<S> FromRequestParts<S> for AuditPrincipal
158where
159    S: Send + Sync,
160{
161    type Rejection = std::convert::Infallible;
162
163    async fn from_request_parts(
164        parts: &mut Parts,
165        _state: &S,
166    ) -> Result<Self, Self::Rejection> {
167        Ok(AuditPrincipal(
168            parts
169                .extensions
170                .get::<AuthenticatedPrincipal>()
171                .map(|p| p.subject.clone()),
172        ))
173    }
174}
175
176/// Build the router with optional bearer-token auth (v0.7.x legacy shape).
177///
178/// When `bearer_token` is `Some(t)`, every request except `GET /health`
179/// + `GET /openapi.json` (unauthenticated probes / machine-readable spec)
180/// requires `Authorization: Bearer t`. v0.8.0 P3 routes this through the
181/// new `AuthValidator::Bearer` middleware so an `AuthenticatedPrincipal`
182/// is attached to every authenticated request (the `TenantExtractor`
183/// reads `principal.tenant_claim` ahead of the `X-Solo-Tenant` header).
184pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
185    let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
186    router_with_auth_config(state, auth)
187}
188
189/// Build the router with a config-driven auth block (v0.8.0 P3+).
190///
191/// `auth = Some(AuthConfig::Bearer { token })` is equivalent to passing
192/// `Some(token)` to [`router_with_auth`]. `auth = Some(AuthConfig::Oidc { … })`
193/// installs the OIDC middleware (JWKS fetch + cache + sig + claim checks).
194/// `auth = None` runs unauthenticated — same `127.0.0.1` default as v0.7.x.
195///
196/// Public routes (`/health`, `/openapi.json`) are always exempt from
197/// auth — load balancers, uptime monitors, and codegen tools shouldn't
198/// need credentials.
199pub fn router_with_auth_config(state: SoloHttpState, auth: Option<AuthConfig>) -> Router {
200    let cors = build_cors_layer();
201    // Public, always-unauthenticated routes:
202    //   - GET /health: liveness probe (load balancers, uptime monitors).
203    //   - GET /openapi.json: machine-readable API description for client
204    //     codegen + browser-UI tooling (TypeScript / OpenAPI Generator,
205    //     curl-tools, etc.). The spec describes the API shape, not
206    //     secrets — fine to serve unauthenticated even on a LAN-bound
207    //     instance.
208    let public = Router::new()
209        .route("/health", get(|| async { "ok" }))
210        .route("/openapi.json", get(openapi_handler));
211
212    let authed = Router::new()
213        .route("/memory", post(remember_handler))
214        .route("/memory/search", post(recall_handler))
215        .route("/memory/consolidate", post(consolidate_handler))
216        .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
217        .route("/backup", post(backup_handler))
218        // Path 1 derived-layer endpoints (v0.4.0+). GET-shaped because
219        // these are pure read-only queries; query-string params for
220        // simple filters keep them curl-friendly without a JSON body.
221        .route("/memory/themes", get(themes_handler))
222        .route("/memory/facts_about", get(facts_about_handler))
223        .route("/memory/contradictions", get(contradictions_handler))
224        // v0.5.0 Priority 3: drill into one cluster + abstraction +
225        // episodes. Two-segment path (`/memory/clusters/{id}`) so it
226        // does not shadow the single-segment `/memory/{id}` UUID
227        // inspect route.
228        .route(
229            "/memory/clusters/{cluster_id}",
230            get(inspect_cluster_handler),
231        )
232        // v0.7.0 P6: document operations. Two-segment paths
233        // (`/memory/documents/...`) so they don't shadow the
234        // single-segment `/memory/{id}` episode-inspect route. Order
235        // matters: register the literal `/memory/documents/search`
236        // ahead of `/memory/documents/{id}` so axum's matcher prefers
237        // the literal over the path parameter.
238        .route(
239            "/memory/documents/search",
240            post(search_docs_handler),
241        )
242        .route(
243            "/memory/documents",
244            post(ingest_document_handler).get(list_documents_handler),
245        )
246        .route(
247            "/memory/documents/{id}",
248            get(inspect_document_handler).delete(forget_document_handler),
249        )
250        .with_state(state.clone());
251
252    let authed = if let Some(cfg) = auth {
253        // v0.8.0 P3: dispatch via AuthValidator (bearer | OIDC), inserts
254        // AuthenticatedPrincipal into request extensions for the
255        // TenantExtractor + audit-log to read.
256        let validator = Arc::new(AuthValidator::from_config(
257            &cfg,
258            state.default_tenant.clone(),
259        ));
260        authed.layer(axum::middleware::from_fn_with_state(
261            validator,
262            crate::auth::middleware::auth_middleware,
263        ))
264    } else {
265        authed
266    };
267
268    public
269        .merge(authed)
270        .layer(cors)
271        .layer(TraceLayer::new_for_http())
272}
273
274/// Convenience wrapper: no auth (loopback-only deployments).
275pub fn router(state: SoloHttpState) -> Router {
276    router_with_auth_config(state, None)
277}
278
279fn build_cors_layer() -> CorsLayer {
280    // Permissive-localhost CORS: allow any localhost / 127.0.0.1 origin so
281    // browser-based UIs running on a different local port can call the API
282    // without preflight friction. We do NOT use `Any` because that would
283    // allow arbitrary remote origins to talk to our localhost server via
284    // a victim's browser. With bearer-token auth enabled the practical
285    // impact is reduced (the cross-origin attacker still can't supply
286    // the token), but principle of least privilege says refuse anyway.
287    //
288    // When the server is bound to a non-loopback address (auth required),
289    // the same CORS predicate keeps localhost-only browser clients —
290    // suitable for trusted-LAN deployments where the LAN client itself
291    // tunnels through ssh/wireguard back to localhost. Wider CORS for
292    // genuine cross-origin browser use is a future config knob.
293    CorsLayer::new()
294        .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
295            origin
296                .to_str()
297                .map(is_localhost_origin)
298                .unwrap_or(false)
299        }))
300        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
301        .allow_headers([
302            axum::http::header::CONTENT_TYPE,
303            axum::http::header::AUTHORIZATION,
304        ])
305}
306
307/// True if `origin` is `http(s)://localhost[:port]` or
308/// `http(s)://127.0.0.1[:port]` or `http(s)://[::1][:port]` (loopback IPv6).
309/// Anything else (incl. nip.io tricks like `127.0.0.1.nip.io`) is rejected.
310fn is_localhost_origin(origin: &str) -> bool {
311    let rest = origin
312        .strip_prefix("http://")
313        .or_else(|| origin.strip_prefix("https://"));
314    let host = match rest {
315        Some(r) => r,
316        None => return false,
317    };
318    // Strip path (shouldn't appear on Origin headers but defend anyway).
319    let host = host.split('/').next().unwrap_or(host);
320    // Strip port.
321    let host = if let Some(idx) = host.rfind(':') {
322        // For [::1]:port, keep the brackets in the host part.
323        if host.starts_with('[') {
324            // Find matching ']'; everything up to and including it is the host.
325            host.find(']')
326                .map(|i| &host[..=i])
327                .unwrap_or(host)
328        } else {
329            &host[..idx]
330        }
331    } else {
332        host
333    };
334    matches!(host, "localhost" | "127.0.0.1" | "[::1]")
335}
336
337/// Bind + serve (v0.7.x legacy shape). `shutdown` is awaited inside
338/// axum's `with_graceful_shutdown`; resolving it triggers a clean drain.
339/// `bearer_token = None` runs unauthenticated (loopback default);
340/// `Some(t)` requires `Authorization: Bearer t` on every request
341/// except `GET /health` + `GET /openapi.json`.
342pub async fn serve_http(
343    addr: SocketAddr,
344    state: SoloHttpState,
345    bearer_token: Option<String>,
346    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
347) -> std::io::Result<()> {
348    let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
349    serve_http_with_auth_config(addr, state, auth, shutdown).await
350}
351
352/// Bind + serve with a config-driven auth block (v0.8.0 P3+).
353/// `auth = None` runs unauthenticated. See [`router_with_auth_config`]
354/// for the auth-mode semantics.
355pub async fn serve_http_with_auth_config(
356    addr: SocketAddr,
357    state: SoloHttpState,
358    auth: Option<AuthConfig>,
359    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
360) -> std::io::Result<()> {
361    let auth_kind = match &auth {
362        Some(AuthConfig::Bearer { .. }) => "bearer",
363        Some(AuthConfig::Oidc { .. }) => "oidc",
364        None => "none",
365    };
366    let app = router_with_auth_config(state, auth);
367    let listener = tokio::net::TcpListener::bind(addr).await?;
368    tracing::info!(%addr, auth = auth_kind, "solo http: listening");
369    axum::serve(listener, app)
370        .with_graceful_shutdown(shutdown)
371        .await
372}
373
374// ---------------------------------------------------------------------------
375// OpenAPI 3.1 spec
376// ---------------------------------------------------------------------------
377
378/// Serve the hand-crafted OpenAPI 3.1 spec at `GET /openapi.json`.
379///
380/// We keep the spec hand-written (rather than deriving via `utoipa`)
381/// for v0.1: 4 simple endpoints, types live across crate boundaries
382/// (`solo_query::RecallResult`, `solo_query::EpisodeRecord`), and a
383/// `utoipa` retrofit would touch every crate. Hand-crafted is one
384/// JSON literal in this file; a smoke test in `handler_tests` parses
385/// the response and asserts the expected paths + components are
386/// present, so drift between spec and code is caught at PR time.
387async fn openapi_handler() -> Json<serde_json::Value> {
388    Json(openapi_spec())
389}
390
391/// Build the OpenAPI 3.1 spec describing Solo's HTTP transport.
392/// Public so the smoke test + future client-codegen tooling can
393/// produce the same document without spinning up the server.
394pub fn openapi_spec() -> serde_json::Value {
395    serde_json::json!({
396        "openapi": "3.1.0",
397        "info": {
398            "title": "Solo HTTP API",
399            "description":
400                "Local-first personal memory daemon. The HTTP transport \
401                 mirrors the four MCP tools (memory_remember / recall / \
402                 inspect / forget). Default deployment is loopback-only \
403                 (127.0.0.1); LAN-bound deployments require a bearer \
404                 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
405            "version": env!("CARGO_PKG_VERSION"),
406            "license": { "name": "Apache-2.0" }
407        },
408        "servers": [
409            { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
410        ],
411        "components": {
412            "securitySchemes": {
413                "bearerAuth": {
414                    "type": "http",
415                    "scheme": "bearer",
416                    "description":
417                        "Bearer-token auth. Required only on LAN-bound deployments \
418                         (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
419                         the default `127.0.0.1` deployment is unauthenticated. \
420                         `GET /health` and `GET /openapi.json` are exempt from auth even \
421                         on bearer-protected instances."
422                }
423            },
424            "schemas": {
425                "RememberRequest": {
426                    "type": "object",
427                    "required": ["content"],
428                    "properties": {
429                        "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
430                        "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
431                        "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
432                    },
433                    "additionalProperties": false
434                },
435                "RememberResponse": {
436                    "type": "object",
437                    "required": ["memory_id"],
438                    "properties": {
439                        "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
440                    }
441                },
442                "RecallRequest": {
443                    "type": "object",
444                    "required": ["query"],
445                    "properties": {
446                        "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
447                        "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
448                    },
449                    "additionalProperties": false
450                },
451                "RecallResult": {
452                    "type": "object",
453                    "description":
454                        "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
455                         see `solo_query::RecallResult` in the source for the canonical shape. \
456                         Treat as a forward-compatible JSON object.",
457                    "additionalProperties": true
458                },
459                "ConsolidationScope": {
460                    "type": "object",
461                    "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
462                    "properties": {
463                        "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
464                        "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." }
465                    },
466                    "additionalProperties": false
467                },
468                "ConsolidationReport": {
469                    "type": "object",
470                    "required": [
471                        "episodes_seen", "clusters_built", "clusters_merged",
472                        "clusters_absorbed", "existing_clusters_merged",
473                        "episodes_clustered", "abstractions_built",
474                        "abstractions_regenerated", "triples_built",
475                        "contradictions_found"
476                    ],
477                    "properties": {
478                        "episodes_seen":             { "type": "integer", "minimum": 0 },
479                        "clusters_built":            { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
480                        "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." },
481                        "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." },
482                        "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." },
483                        "episodes_clustered":        { "type": "integer", "minimum": 0 },
484                        "abstractions_built":        { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
485                        "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." },
486                        "triples_built":             { "type": "integer", "minimum": 0 },
487                        "contradictions_found":      { "type": "integer", "minimum": 0 }
488                    }
489                },
490                "EpisodeRecord": {
491                    "type": "object",
492                    "description":
493                        "Inspect response: full episode record. Fields are stable across v0.1 but not \
494                         exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
495                         Treat as a forward-compatible JSON object.",
496                    "additionalProperties": true
497                },
498                "ThemeHit": {
499                    "type": "object",
500                    "description":
501                        "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
502                         See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
503                         abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
504                    "additionalProperties": true
505                },
506                "FactHit": {
507                    "type": "object",
508                    "description":
509                        "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
510                         See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
511                         object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
512                    "additionalProperties": true
513                },
514                "ContradictionHit": {
515                    "type": "object",
516                    "description":
517                        "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
518                         Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
519                         a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
520                    "additionalProperties": true
521                },
522                "ClusterRecord": {
523                    "type": "object",
524                    "description":
525                        "Snapshot of one cluster — its row, optional abstraction, and source episodes \
526                         (content truncated to 200 chars unless ?full_content=true). Returned by \
527                         GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
528                    "additionalProperties": true
529                },
530                "IngestDocumentRequest": {
531                    "type": "object",
532                    "required": ["path"],
533                    "properties": {
534                        "path": {
535                            "type": "string",
536                            "minLength": 1,
537                            "description":
538                                "Server-side absolute path to the file to ingest. The file must be \
539                                 readable by the Solo process. Supported formats: plaintext / \
540                                 markdown / code, HTML, PDF."
541                        }
542                    },
543                    "additionalProperties": false
544                },
545                "IngestReport": {
546                    "type": "object",
547                    "description":
548                        "Returned by POST /memory/documents. Reports the document id assigned, \
549                         the number of chunks persisted + embedded, the total byte size, and a \
550                         `deduped` flag (true when the same content_hash was already present and \
551                         the existing doc_id was returned unchanged). See `solo_storage::IngestReport`.",
552                    "required": ["doc_id", "chunks_persisted", "bytes_ingested", "deduped"],
553                    "properties": {
554                        "doc_id":            { "type": "string", "format": "uuid" },
555                        "chunks_persisted":  { "type": "integer", "minimum": 0 },
556                        "bytes_ingested":    { "type": "integer", "minimum": 0, "format": "int64" },
557                        "deduped":           { "type": "boolean" }
558                    },
559                    "additionalProperties": false
560                },
561                "ForgetDocumentReport": {
562                    "type": "object",
563                    "description":
564                        "Returned by DELETE /memory/documents/{id}. Reports the doc_id soft-deleted \
565                         and how many chunk rowids were tombstoned in the HNSW index. The chunk rows \
566                         themselves survive in SQL for forensic value. See `solo_storage::ForgetDocumentReport`.",
567                    "required": ["doc_id", "chunks_tombstoned"],
568                    "properties": {
569                        "doc_id":             { "type": "string", "format": "uuid" },
570                        "chunks_tombstoned":  { "type": "integer", "minimum": 0 }
571                    },
572                    "additionalProperties": false
573                },
574                "SearchDocsRequest": {
575                    "type": "object",
576                    "required": ["query"],
577                    "properties": {
578                        "query": { "type": "string", "minLength": 1 },
579                        "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }
580                    },
581                    "additionalProperties": false
582                },
583                "DocSearchHit": {
584                    "type": "object",
585                    "description":
586                        "One chunk hit + parent-doc context. Fields per `solo_query::DocSearchHit`: \
587                         chunk_id, doc_id, doc_title?, doc_source?, doc_mime_type?, chunk_index, \
588                         content, cos_distance, start_offset, end_offset.",
589                    "additionalProperties": true
590                },
591                "DocumentInspectResult": {
592                    "type": "object",
593                    "description":
594                        "Returned by GET /memory/documents/{id}. A `document` record (full metadata) \
595                         plus an ordered list of chunk summaries (each preview truncated to 200 \
596                         chars). See `solo_query::DocumentInspectResult`.",
597                    "additionalProperties": true
598                },
599                "DocumentSummary": {
600                    "type": "object",
601                    "description":
602                        "One row from GET /memory/documents. Fields per `solo_query::DocumentSummary`: \
603                         doc_id, title?, source?, mime_type?, ingested_at_ms, chunk_count, status.",
604                    "additionalProperties": true
605                },
606                "ApiError": {
607                    "type": "object",
608                    "required": ["error", "status"],
609                    "properties": {
610                        "error": { "type": "string" },
611                        "status": { "type": "integer", "minimum": 400, "maximum": 599 }
612                    }
613                }
614            }
615        },
616        "paths": {
617            "/health": {
618                "get": {
619                    "summary": "Liveness probe",
620                    "description": "Returns plain text `ok`. Always unauthenticated.",
621                    "responses": {
622                        "200": {
623                            "description": "Server is up.",
624                            "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
625                        }
626                    }
627                }
628            },
629            "/openapi.json": {
630                "get": {
631                    "summary": "Self-describing OpenAPI 3.1 spec",
632                    "description": "Returns this document. Always unauthenticated.",
633                    "responses": {
634                        "200": {
635                            "description": "OpenAPI 3.1 document.",
636                            "content": { "application/json": { "schema": { "type": "object" } } }
637                        }
638                    }
639                }
640            },
641            "/memory": {
642                "post": {
643                    "summary": "Remember (store an episode)",
644                    "description": "Equivalent to MCP tool `memory_remember`.",
645                    "security": [{ "bearerAuth": [] }, {}],
646                    "requestBody": {
647                        "required": true,
648                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
649                    },
650                    "responses": {
651                        "200": {
652                            "description": "Memory stored; returns the new MemoryId.",
653                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
654                        },
655                        "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
656                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
657                    }
658                }
659            },
660            "/memory/search": {
661                "post": {
662                    "summary": "Recall (vector search)",
663                    "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
664                    "security": [{ "bearerAuth": [] }, {}],
665                    "requestBody": {
666                        "required": true,
667                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
668                    },
669                    "responses": {
670                        "200": {
671                            "description": "Search results.",
672                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
673                        },
674                        "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
675                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
676                    }
677                }
678            },
679            "/memory/consolidate": {
680                "post": {
681                    "summary": "Run a consolidation pass (clustering + abstraction)",
682                    "description":
683                        "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
684                         on the server, also runs the REM-equivalent abstraction pass that populates \
685                         `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
686                         window). Equivalent to the `solo consolidate` CLI.",
687                    "security": [{ "bearerAuth": [] }, {}],
688                    "requestBody": {
689                        "required": false,
690                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
691                    },
692                    "responses": {
693                        "200": {
694                            "description": "Consolidation complete; report counts the work done.",
695                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
696                        },
697                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
698                    }
699                }
700            },
701            "/backup": {
702                "post": {
703                    "summary": "Online encrypted backup",
704                    "description":
705                        "Run an online SQLCipher backup of the live data dir to a server-side path. \
706                         The destination file is encrypted with the same Argon2id-derived raw key as \
707                         the source, so it restores under the same passphrase + a copy of the source's \
708                         `solo.config.toml`. Hot — the backup runs against the writer's existing \
709                         connection without taking the lockfile, so the daemon keeps serving reads + \
710                         writes during the operation. v0.3.2+.",
711                    "security": [{ "bearerAuth": [] }, {}],
712                    "requestBody": {
713                        "required": true,
714                        "content": { "application/json": { "schema": {
715                            "type": "object",
716                            "properties": {
717                                "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
718                                "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
719                            },
720                            "required": ["to"]
721                        } } }
722                    },
723                    "responses": {
724                        "200": {
725                            "description": "Backup complete; reports the destination path + elapsed milliseconds.",
726                            "content": { "application/json": { "schema": {
727                                "type": "object",
728                                "properties": {
729                                    "path": { "type": "string" },
730                                    "elapsed_ms": { "type": "integer", "format": "int64" }
731                                }
732                            } } }
733                        },
734                        "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
735                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
736                        "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
737                    }
738                }
739            },
740            "/memory/{id}": {
741                "get": {
742                    "summary": "Inspect a memory by ID",
743                    "description": "Equivalent to MCP tool `memory_inspect`.",
744                    "security": [{ "bearerAuth": [] }, {}],
745                    "parameters": [{
746                        "name": "id",
747                        "in": "path",
748                        "required": true,
749                        "schema": { "type": "string", "format": "uuid" },
750                        "description": "MemoryId (UUID v7)."
751                    }],
752                    "responses": {
753                        "200": {
754                            "description": "Episode record.",
755                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
756                        },
757                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
758                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
759                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
760                    }
761                },
762                "delete": {
763                    "summary": "Forget (soft-delete) a memory by ID",
764                    "description":
765                        "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
766                         and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
767                         re-running `solo reembed` after this does NOT restore visibility.",
768                    "security": [{ "bearerAuth": [] }, {}],
769                    "parameters": [
770                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
771                        { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
772                    ],
773                    "responses": {
774                        "204": { "description": "Forgotten (or already forgotten — idempotent)." },
775                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
776                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
777                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
778                    }
779                }
780            },
781            "/memory/themes": {
782                "get": {
783                    "summary": "List recent cluster themes",
784                    "description":
785                        "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
786                         most-recent first. Use to surface 'what has the user been thinking about lately' \
787                         without paging through individual episodes. v0.4.0+.",
788                    "security": [{ "bearerAuth": [] }, {}],
789                    "parameters": [
790                        { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
791                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
792                    ],
793                    "responses": {
794                        "200": {
795                            "description": "Array of ThemeHits (possibly empty).",
796                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
797                        },
798                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
799                    }
800                }
801            },
802            "/memory/facts_about": {
803                "get": {
804                    "summary": "Query the SPO knowledge graph by subject",
805                    "description":
806                        "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
807                         subject + optional predicate + optional time window. Subject is required \
808                         (predicate-only scans not supported). Pass `include_as_object=true` (v0.5.1+) \
809                         to also surface rows where `subject` appears as the object. v0.4.0+.",
810                    "security": [{ "bearerAuth": [] }, {}],
811                    "parameters": [
812                        { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
813                        { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
814                        { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
815                        { "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." },
816                        { "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+." },
817                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
818                    ],
819                    "responses": {
820                        "200": {
821                            "description": "Array of FactHits (possibly empty).",
822                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
823                        },
824                        "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
825                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
826                    }
827                }
828            },
829            "/memory/contradictions": {
830                "get": {
831                    "summary": "List Steward-flagged contradictions",
832                    "description":
833                        "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
834                         sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
835                    "security": [{ "bearerAuth": [] }, {}],
836                    "parameters": [
837                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
838                    ],
839                    "responses": {
840                        "200": {
841                            "description": "Array of ContradictionHits (possibly empty).",
842                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
843                        },
844                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
845                    }
846                }
847            },
848            "/memory/clusters/{cluster_id}": {
849                "get": {
850                    "summary": "Inspect a single cluster",
851                    "description":
852                        "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
853                         its (optional) abstraction, and its source episodes. By default each \
854                         episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
855                         `?full_content=true` to get verbatim episode content. v0.5.0+.",
856                    "security": [{ "bearerAuth": [] }, {}],
857                    "parameters": [
858                        { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
859                        { "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)." }
860                    ],
861                    "responses": {
862                        "200": {
863                            "description": "Cluster snapshot.",
864                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
865                        },
866                        "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
867                        "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
868                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
869                    }
870                }
871            },
872            "/memory/documents": {
873                "post": {
874                    "summary": "Ingest a document",
875                    "description":
876                        "Equivalent to MCP tool `memory_ingest_document`. Reads the file at the \
877                         supplied server-side path, parses + chunks + embeds, and persists under \
878                         `documents` + `document_chunks`. Returns the new doc_id, chunk count, and \
879                         a `deduped` flag (true when an existing document with the same content_hash \
880                         was returned without re-embedding). v0.7.0+.",
881                    "security": [{ "bearerAuth": [] }, {}],
882                    "requestBody": {
883                        "required": true,
884                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestDocumentRequest" } } }
885                    },
886                    "responses": {
887                        "200": {
888                            "description": "Document ingested (or deduplicated).",
889                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestReport" } } }
890                        },
891                        "400": { "description": "Bad request (e.g. empty path, file unreadable, parse error).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
892                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
893                    }
894                },
895                "get": {
896                    "summary": "List ingested documents (paginated)",
897                    "description":
898                        "Equivalent to MCP tool `memory_list_documents`. Returns a paginated index, \
899                         newest first. Forgotten documents are hidden by default; pass \
900                         `?include_forgotten=true` to see them too. v0.7.0+.",
901                    "security": [{ "bearerAuth": [] }, {}],
902                    "parameters": [
903                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
904                        { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
905                        { "name": "include_forgotten", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
906                    ],
907                    "responses": {
908                        "200": {
909                            "description": "Array of DocumentSummary (possibly empty).",
910                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentSummary" } } } }
911                        },
912                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
913                    }
914                }
915            },
916            "/memory/documents/search": {
917                "post": {
918                    "summary": "Vector search across document chunks",
919                    "description":
920                        "Equivalent to MCP tool `memory_search_docs`. Embeds the query and returns \
921                         up to `limit` matching chunks, best match first, each annotated with the \
922                         parent document's title + source path. Forgotten documents are excluded. \
923                         v0.7.0+.",
924                    "security": [{ "bearerAuth": [] }, {}],
925                    "requestBody": {
926                        "required": true,
927                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchDocsRequest" } } }
928                    },
929                    "responses": {
930                        "200": {
931                            "description": "Array of DocSearchHits (possibly empty).",
932                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocSearchHit" } } } }
933                        },
934                        "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
935                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
936                    }
937                }
938            },
939            "/memory/documents/{id}": {
940                "get": {
941                    "summary": "Inspect one document",
942                    "description":
943                        "Equivalent to MCP tool `memory_inspect_document`. Returns the document's \
944                         metadata plus a preview of every chunk (truncated to 200 chars). v0.7.0+.",
945                    "security": [{ "bearerAuth": [] }, {}],
946                    "parameters": [
947                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "DocumentId (UUID v7)." }
948                    ],
949                    "responses": {
950                        "200": {
951                            "description": "Document inspection result.",
952                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentInspectResult" } } }
953                        },
954                        "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
955                        "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
956                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
957                    }
958                },
959                "delete": {
960                    "summary": "Forget (soft-delete) one document",
961                    "description":
962                        "Equivalent to MCP tool `memory_forget_document`. Flips `documents.status` \
963                         to `forgotten` and tombstones every chunk's HNSW rowid. The chunk rows \
964                         survive in SQL for forensic value. v0.7.0+.",
965                    "security": [{ "bearerAuth": [] }, {}],
966                    "parameters": [
967                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
968                    ],
969                    "responses": {
970                        "200": {
971                            "description": "Document soft-deleted; report counts chunks tombstoned.",
972                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgetDocumentReport" } } }
973                        },
974                        "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
975                        "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
976                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
977                    }
978                }
979            }
980        }
981    })
982}
983
984// ---------------------------------------------------------------------------
985// Handlers
986// ---------------------------------------------------------------------------
987
988#[derive(Debug, Deserialize)]
989struct RememberBody {
990    content: String,
991    #[serde(default)]
992    source_type: Option<String>,
993    #[serde(default)]
994    source_id: Option<String>,
995}
996
997#[derive(Debug, Serialize)]
998struct RememberResponse {
999    memory_id: String,
1000}
1001
1002async fn remember_handler(
1003    TenantExtractor(tenant): TenantExtractor,
1004    AuditPrincipal(principal): AuditPrincipal,
1005    Json(body): Json<RememberBody>,
1006) -> Result<Json<RememberResponse>, ApiError> {
1007    let content = body.content.trim_end().to_string();
1008    if content.is_empty() {
1009        return Err(ApiError::bad_request("content must not be empty"));
1010    }
1011    let embedding = tenant.embedder().embed(&content).await.map_err(ApiError::from)?;
1012    let episode = Episode {
1013        memory_id: MemoryId::new(),
1014        ts_ms: chrono::Utc::now().timestamp_millis(),
1015        source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
1016        source_id: body.source_id,
1017        content,
1018        encoding_context: EncodingContext::default(),
1019        provenance: None,
1020        confidence: Confidence::new(0.9).unwrap(),
1021        strength: 0.5,
1022        salience: 0.5,
1023        tier: Tier::Hot,
1024    };
1025    let mid = tenant
1026        .write()
1027        .remember_as(principal, episode, embedding)
1028        .await
1029        .map_err(ApiError::from)?;
1030    Ok(Json(RememberResponse {
1031        memory_id: mid.to_string(),
1032    }))
1033}
1034
1035#[derive(Debug, Deserialize)]
1036struct RecallBody {
1037    query: String,
1038    #[serde(default = "default_limit")]
1039    limit: usize,
1040}
1041
1042fn default_limit() -> usize {
1043    5
1044}
1045
1046async fn recall_handler(
1047    TenantExtractor(tenant): TenantExtractor,
1048    AuditPrincipal(principal): AuditPrincipal,
1049    Json(body): Json<RecallBody>,
1050) -> Result<Json<solo_query::RecallResult>, ApiError> {
1051    // solo_query::run_recall handles empty-query rejection (returns
1052    // InvalidInput → ApiError::bad_request(400)) and clamps limit
1053    // upstream of the embedder call.
1054    let result = solo_query::run_recall(tenant.as_ref(), principal, &body.query, body.limit)
1055        .await
1056        .map_err(ApiError::from)?;
1057    Ok(Json(result))
1058}
1059
1060async fn inspect_handler(
1061    TenantExtractor(tenant): TenantExtractor,
1062    AuditPrincipal(principal): AuditPrincipal,
1063    Path(id): Path<String>,
1064) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
1065    let mid = MemoryId::from_str(&id)
1066        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1067    let row = solo_query::inspect_one(tenant.read(), tenant.audit(), principal, mid)
1068        .await
1069        .map_err(ApiError::from)?;
1070    Ok(Json(row))
1071}
1072
1073// Path 1 derived-layer handlers (v0.4.0+). All three are GET-shaped:
1074// pure read-only queries against the Steward's outputs, query-string
1075// params for simple filters. Each handler delegates to a single
1076// solo_query::derived pipeline and returns the result Vec as JSON.
1077// Empty derived layer → 200 with `[]` body (parseable JSON array).
1078
1079#[derive(Debug, Deserialize)]
1080struct ThemesQuery {
1081    #[serde(default)]
1082    window_days: Option<i64>,
1083    #[serde(default = "default_limit")]
1084    limit: usize,
1085}
1086
1087async fn themes_handler(
1088    TenantExtractor(tenant): TenantExtractor,
1089    AuditPrincipal(principal): AuditPrincipal,
1090    Query(q): Query<ThemesQuery>,
1091) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
1092    let hits = solo_query::themes(
1093        tenant.read(),
1094        tenant.audit(),
1095        principal,
1096        q.window_days,
1097        q.limit,
1098    )
1099    .await
1100    .map_err(ApiError::from)?;
1101    Ok(Json(hits))
1102}
1103
1104#[derive(Debug, Deserialize)]
1105struct FactsAboutQuery {
1106    subject: String,
1107    #[serde(default)]
1108    predicate: Option<String>,
1109    #[serde(default)]
1110    since_ms: Option<i64>,
1111    #[serde(default)]
1112    until_ms: Option<i64>,
1113    /// v0.5.1 Priority 8 — widen the query to also match rows where
1114    /// `subject` appears as the object. Default `false`.
1115    #[serde(default)]
1116    include_as_object: bool,
1117    #[serde(default = "default_limit")]
1118    limit: usize,
1119}
1120
1121async fn facts_about_handler(
1122    State(s): State<SoloHttpState>,
1123    TenantExtractor(tenant): TenantExtractor,
1124    AuditPrincipal(principal): AuditPrincipal,
1125    Query(q): Query<FactsAboutQuery>,
1126) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
1127    if q.subject.trim().is_empty() {
1128        return Err(ApiError::bad_request("subject must not be empty"));
1129    }
1130    let hits = solo_query::facts_about(
1131        tenant.read(),
1132        tenant.audit(),
1133        principal,
1134        &q.subject,
1135        &s.user_aliases,
1136        q.include_as_object,
1137        q.predicate.as_deref(),
1138        q.since_ms,
1139        q.until_ms,
1140        q.limit,
1141    )
1142    .await
1143    .map_err(ApiError::from)?;
1144    Ok(Json(hits))
1145}
1146
1147#[derive(Debug, Deserialize)]
1148struct ContradictionsQuery {
1149    #[serde(default = "default_limit")]
1150    limit: usize,
1151}
1152
1153async fn contradictions_handler(
1154    TenantExtractor(tenant): TenantExtractor,
1155    AuditPrincipal(principal): AuditPrincipal,
1156    Query(q): Query<ContradictionsQuery>,
1157) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
1158    let hits = solo_query::contradictions(tenant.read(), tenant.audit(), principal, q.limit)
1159        .await
1160        .map_err(ApiError::from)?;
1161    Ok(Json(hits))
1162}
1163
1164#[derive(Debug, Deserialize, Default)]
1165struct InspectClusterQuery {
1166    /// Default `false` — episode `content` is truncated to
1167    /// `solo_query::EPISODE_TRUNCATE_CHARS` chars with a trailing `…`.
1168    /// `?full_content=true` returns each episode's content verbatim.
1169    #[serde(default)]
1170    full_content: bool,
1171}
1172
1173async fn inspect_cluster_handler(
1174    TenantExtractor(tenant): TenantExtractor,
1175    AuditPrincipal(principal): AuditPrincipal,
1176    Path(cluster_id): Path<String>,
1177    Query(q): Query<InspectClusterQuery>,
1178) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
1179    if cluster_id.trim().is_empty() {
1180        return Err(ApiError::bad_request("cluster_id must not be empty"));
1181    }
1182    let record = solo_query::inspect_cluster(
1183        tenant.read(),
1184        tenant.audit(),
1185        principal,
1186        &cluster_id,
1187        q.full_content,
1188    )
1189    .await
1190    .map_err(ApiError::from)?;
1191    Ok(Json(record))
1192}
1193
1194// ---------------------------------------------------------------------------
1195// Document handlers (v0.7.0 P6)
1196// ---------------------------------------------------------------------------
1197
1198#[derive(Debug, Deserialize)]
1199struct IngestDocumentBody {
1200    /// Server-side absolute path to the file. Must be readable by the
1201    /// Solo process. The writer reads, parses, chunks, and embeds.
1202    path: String,
1203}
1204
1205async fn ingest_document_handler(
1206    TenantExtractor(tenant): TenantExtractor,
1207    AuditPrincipal(principal): AuditPrincipal,
1208    Json(body): Json<IngestDocumentBody>,
1209) -> Result<Json<solo_storage::IngestReport>, ApiError> {
1210    if body.path.trim().is_empty() {
1211        return Err(ApiError::bad_request("path must not be empty"));
1212    }
1213    let path = std::path::PathBuf::from(body.path);
1214    let chunk_config = solo_storage::document::ChunkConfig::default();
1215    let report = tenant
1216        .write()
1217        .ingest_document_as(principal, path, chunk_config)
1218        .await
1219        .map_err(ApiError::from)?;
1220    Ok(Json(report))
1221}
1222
1223#[derive(Debug, Deserialize)]
1224struct SearchDocsBody {
1225    query: String,
1226    #[serde(default = "default_limit")]
1227    limit: usize,
1228}
1229
1230async fn search_docs_handler(
1231    TenantExtractor(tenant): TenantExtractor,
1232    AuditPrincipal(principal): AuditPrincipal,
1233    Json(body): Json<SearchDocsBody>,
1234) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
1235    let hits = solo_query::run_doc_search(tenant.as_ref(), principal, &body.query, body.limit)
1236        .await
1237        .map_err(ApiError::from)?;
1238    Ok(Json(hits))
1239}
1240
1241async fn inspect_document_handler(
1242    TenantExtractor(tenant): TenantExtractor,
1243    AuditPrincipal(principal): AuditPrincipal,
1244    Path(id): Path<String>,
1245) -> Result<Json<solo_query::DocumentInspectResult>, ApiError> {
1246    let doc_id = DocumentId::from_str(&id)
1247        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1248    let result_opt =
1249        solo_query::inspect_document(tenant.read(), tenant.audit(), principal, &doc_id)
1250            .await
1251            .map_err(ApiError::from)?;
1252    match result_opt {
1253        Some(record) => Ok(Json(record)),
1254        None => Err(ApiError::not_found(format!("document {doc_id} not found"))),
1255    }
1256}
1257
1258#[derive(Debug, Deserialize)]
1259struct ListDocumentsQuery {
1260    #[serde(default = "default_list_documents_limit")]
1261    limit: usize,
1262    #[serde(default)]
1263    offset: usize,
1264    #[serde(default)]
1265    include_forgotten: bool,
1266}
1267
1268fn default_list_documents_limit() -> usize {
1269    20
1270}
1271
1272async fn list_documents_handler(
1273    TenantExtractor(tenant): TenantExtractor,
1274    AuditPrincipal(principal): AuditPrincipal,
1275    Query(q): Query<ListDocumentsQuery>,
1276) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
1277    let rows = solo_query::list_documents(
1278        tenant.read(),
1279        tenant.audit(),
1280        principal,
1281        q.limit,
1282        q.offset,
1283        q.include_forgotten,
1284    )
1285    .await
1286    .map_err(ApiError::from)?;
1287    Ok(Json(rows))
1288}
1289
1290async fn forget_document_handler(
1291    TenantExtractor(tenant): TenantExtractor,
1292    AuditPrincipal(principal): AuditPrincipal,
1293    Path(id): Path<String>,
1294) -> Result<Json<solo_storage::ForgetDocumentReport>, ApiError> {
1295    let doc_id = DocumentId::from_str(&id)
1296        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1297    let report = tenant
1298        .write()
1299        .forget_document_as(principal, doc_id)
1300        .await
1301        .map_err(ApiError::from)?;
1302    Ok(Json(report))
1303}
1304
1305#[derive(Debug, Deserialize)]
1306struct ForgetQuery {
1307    #[serde(default)]
1308    reason: Option<String>,
1309}
1310
1311async fn forget_handler(
1312    TenantExtractor(tenant): TenantExtractor,
1313    AuditPrincipal(principal): AuditPrincipal,
1314    Path(id): Path<String>,
1315    Query(q): Query<ForgetQuery>,
1316) -> Result<StatusCode, ApiError> {
1317    let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1318    let reason = q.reason.unwrap_or_else(|| "http".into());
1319    tenant
1320        .write()
1321        .forget_as(principal, mid, reason)
1322        .await
1323        .map_err(ApiError::from)?;
1324    Ok(StatusCode::NO_CONTENT)
1325}
1326
1327async fn consolidate_handler(
1328    TenantExtractor(tenant): TenantExtractor,
1329    AuditPrincipal(principal): AuditPrincipal,
1330    body: axum::body::Bytes,
1331) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
1332    // Empty body = default scope (unbounded window). We parse via
1333    // `Bytes` rather than `Option<Json<T>>` because axum's `Json`
1334    // extractor 400s on an empty body when Content-Type is JSON
1335    // (it can't deserialize zero bytes as `T`), and the `Option`
1336    // wrapper doesn't reliably degrade that failure to `None`.
1337    let scope = if body.is_empty() {
1338        solo_storage::ConsolidationScope::default()
1339    } else {
1340        serde_json::from_slice(&body)
1341            .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
1342    };
1343    let report = tenant
1344        .write()
1345        .consolidate_as(principal, scope)
1346        .await
1347        .map_err(ApiError::from)?;
1348    Ok(Json(report))
1349}
1350
1351#[derive(Debug, Deserialize)]
1352struct BackupBody {
1353    /// Server-side absolute path where the backup file should be
1354    /// written. Must be writable by the Solo process. Refuses to
1355    /// overwrite an existing file unless `force = true`.
1356    to: String,
1357    #[serde(default)]
1358    force: bool,
1359}
1360
1361#[derive(Debug, Serialize)]
1362struct BackupResponse {
1363    path: String,
1364    elapsed_ms: u64,
1365}
1366
1367async fn backup_handler(
1368    TenantExtractor(tenant): TenantExtractor,
1369    Json(body): Json<BackupBody>,
1370) -> Result<Json<BackupResponse>, ApiError> {
1371    use std::path::PathBuf;
1372
1373    let dest = PathBuf::from(&body.to);
1374    if dest.as_os_str().is_empty() {
1375        return Err(ApiError::bad_request("`to` must not be empty"));
1376    }
1377    // CRITICAL ORDER: same-file refusal MUST come BEFORE `remove_file`.
1378    // The tenant's source DB path comes from the resolved TenantHandle.
1379    if solo_storage::paths_refer_to_same_file(tenant.db_path(), &dest) {
1380        return Err(ApiError::bad_request(format!(
1381            "destination {} is the same file as the source database; \
1382             refusing to run (would corrupt the live database)",
1383            dest.display()
1384        )));
1385    }
1386    if dest.exists() {
1387        if !body.force {
1388            return Err(ApiError::bad_request(format!(
1389                "destination {} exists; pass force=true to overwrite",
1390                dest.display()
1391            )));
1392        }
1393        std::fs::remove_file(&dest).map_err(|e| {
1394            ApiError::internal(format!(
1395                "remove existing destination {}: {e}",
1396                dest.display()
1397            ))
1398        })?;
1399    }
1400    if let Some(parent) = dest.parent() {
1401        if !parent.as_os_str().is_empty() && !parent.is_dir() {
1402            return Err(ApiError::bad_request(format!(
1403                "destination parent directory {} does not exist",
1404                parent.display()
1405            )));
1406        }
1407    }
1408
1409    let started = std::time::Instant::now();
1410    tenant.write().backup(dest.clone()).await.map_err(ApiError::from)?;
1411    let elapsed_ms = started.elapsed().as_millis() as u64;
1412
1413    Ok(Json(BackupResponse {
1414        path: dest.display().to_string(),
1415        elapsed_ms,
1416    }))
1417}
1418
1419// ---------------------------------------------------------------------------
1420// Error mapping
1421// ---------------------------------------------------------------------------
1422
1423#[derive(Debug)]
1424pub struct ApiError {
1425    status: StatusCode,
1426    message: String,
1427}
1428
1429impl ApiError {
1430    fn bad_request(msg: impl Into<String>) -> Self {
1431        Self {
1432            status: StatusCode::BAD_REQUEST,
1433            message: msg.into(),
1434        }
1435    }
1436    fn not_found(msg: impl Into<String>) -> Self {
1437        Self {
1438            status: StatusCode::NOT_FOUND,
1439            message: msg.into(),
1440        }
1441    }
1442    fn internal(msg: impl Into<String>) -> Self {
1443        Self {
1444            status: StatusCode::INTERNAL_SERVER_ERROR,
1445            message: msg.into(),
1446        }
1447    }
1448}
1449
1450impl From<solo_core::Error> for ApiError {
1451    fn from(e: solo_core::Error) -> Self {
1452        use solo_core::Error;
1453        match e {
1454            Error::NotFound(msg) => ApiError::not_found(msg),
1455            Error::InvalidInput(msg) => ApiError::bad_request(msg),
1456            Error::Conflict(msg) => Self {
1457                status: StatusCode::CONFLICT,
1458                message: msg,
1459            },
1460            other => ApiError::internal(other.to_string()),
1461        }
1462    }
1463}
1464
1465impl IntoResponse for ApiError {
1466    fn into_response(self) -> Response {
1467        let body = serde_json::json!({
1468            "error": self.message,
1469            "status": self.status.as_u16(),
1470        });
1471        (self.status, Json(body)).into_response()
1472    }
1473}
1474
1475// SQL helper for recall used to live here; consolidated into
1476// solo_query::recall.
1477
1478#[cfg(test)]
1479mod handler_tests {
1480    //! In-process integration tests for the HTTP handler surface. We
1481    //! drive the axum Router directly via `tower::ServiceExt::oneshot`
1482    //! — no real TCP listener needed. Same `Harness`-shape as the MCP
1483    //! tests: real WriterActor + ReaderPool + StubEmbedder + StubVectorIndex.
1484    //!
1485    //! Tests live inline in this module rather than in a `tests/` dir
1486    //! because external integration-test exes triggered Windows UAC
1487    //! ERROR_ELEVATION_REQUIRED on the dev machine.
1488    use super::*;
1489    use axum::body::Body;
1490    use axum::http::{Request, StatusCode};
1491    use http_body_util::BodyExt;
1492    use serde_json::{Value, json};
1493    use solo_storage::test_support::StubVectorIndex;
1494    use solo_storage::{
1495        EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1496        StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1497    };
1498    use solo_core::VectorIndex;
1499    use std::sync::Arc as StdArc;
1500    use tower::ServiceExt;
1501
1502    fn fake_config(dim: u32) -> SoloConfig {
1503        SoloConfig {
1504            schema_version: 1,
1505            salt_hex: "00000000000000000000000000000000".to_string(),
1506            embedder: EmbedderConfig {
1507                name: "stub".to_string(),
1508                version: "v1".to_string(),
1509                dim,
1510                dtype: "f32".to_string(),
1511            },
1512            identity: IdentityConfig::default(),
1513            documents: solo_storage::DocumentConfig::default(),
1514            auth: None,
1515            audit: solo_storage::AuditSettings::default(),
1516            redaction: solo_storage::RedactionConfig::default(),
1517            llm: None,
1518            triples: solo_storage::TriplesConfig::default(),
1519            sampling: solo_storage::SamplingConfig::default(),
1520        }
1521    }
1522
1523    struct Harness {
1524        router: axum::Router,
1525        _tmp: tempfile::TempDir,
1526        write_handle_extra: Option<solo_storage::WriteHandle>,
1527        join: Option<std::thread::JoinHandle<()>>,
1528    }
1529
1530    impl Harness {
1531        fn new(runtime: &tokio::runtime::Runtime) -> Self {
1532            Self::new_with_auth(runtime, None)
1533        }
1534
1535        fn new_with_auth(
1536            runtime: &tokio::runtime::Runtime,
1537            bearer_token: Option<String>,
1538        ) -> Self {
1539            Self::new_with_auth_config(
1540                runtime,
1541                bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
1542            )
1543        }
1544
1545        fn new_with_auth_config(
1546            runtime: &tokio::runtime::Runtime,
1547            auth: Option<crate::auth::AuthConfig>,
1548        ) -> Self {
1549            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1550
1551            let tmp = tempfile::TempDir::new().unwrap();
1552            let dim = 16usize;
1553            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1554            let embedder: StdArc<dyn solo_core::Embedder> =
1555                StdArc::new(StubEmbedder::new("stub", "v1", dim));
1556            let path = tmp.path().join("test.db");
1557
1558            let embedder_id = {
1559                let conn = solo_storage::test_support::open_test_db_at(&path);
1560                get_or_insert_embedder_id(
1561                    &conn,
1562                    &EmbedderIdentity {
1563                        name: "stub".into(),
1564                        version: "v1".into(),
1565                        dim: dim as u32,
1566                        dtype: "f32".into(),
1567                    },
1568                )
1569                .unwrap()
1570            };
1571
1572            let conn = solo_storage::test_support::open_test_db_at(&path);
1573            let WriterSpawn { handle, join } = WriterActor::spawn_full(
1574                conn,
1575                hnsw.clone(),
1576                tmp.path().to_path_buf(),
1577                embedder_id,
1578            );
1579            let pool: ReaderPool =
1580                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1581
1582            // Build a TenantHandle from the assembled parts and wrap it
1583            // in a single-tenant test registry.
1584            let tenant_id = solo_core::TenantId::default_tenant();
1585            let tenant_handle = StdArc::new(
1586                TenantHandle::from_parts_for_tests(
1587                    tenant_id.clone(),
1588                    fake_config(dim as u32),
1589                    path.clone(),
1590                    tmp.path().to_path_buf(),
1591                    embedder_id,
1592                    hnsw,
1593                    embedder.clone(),
1594                    handle.clone(),
1595                    // The harness owns ANOTHER WriteHandle clone + the join.
1596                    // We give the TenantHandle a dummy join that immediately
1597                    // returns — it never gets joined because shutdown_all
1598                    // can't get exclusive Arc ownership when the harness
1599                    // also holds a writer clone.
1600                    std::thread::spawn(|| {}),
1601                    pool,
1602                ),
1603            );
1604
1605            // Suppress the auto-spawned dummy thread by letting it finish.
1606            // We DON'T put the real `join` into the TenantHandle because
1607            // we keep our own clone of `handle` for the shutdown path.
1608            let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1609            let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1610                tmp.path().to_path_buf(),
1611                key,
1612                embedder,
1613                tenant_handle,
1614            ));
1615
1616            let state = SoloHttpState {
1617                registry,
1618                default_tenant: tenant_id,
1619                user_aliases: Arc::new(Vec::new()),
1620            };
1621            let router = router_with_auth_config(state, auth);
1622            Harness {
1623                router,
1624                _tmp: tmp,
1625                write_handle_extra: Some(handle),
1626                join: Some(join),
1627            }
1628        }
1629
1630        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1631            let join = self.join.take();
1632            let extra = self.write_handle_extra.take();
1633            runtime.block_on(async move {
1634                drop(extra);
1635                drop(self.router); // drops state → drops pool inside runtime ctx
1636                drop(self._tmp);
1637                if let Some(join) = join {
1638                    let (tx, rx) = std::sync::mpsc::channel();
1639                    std::thread::spawn(move || {
1640                        let _ = tx.send(join.join());
1641                    });
1642                    tokio::task::spawn_blocking(move || {
1643                        rx.recv_timeout(std::time::Duration::from_secs(5))
1644                    })
1645                    .await
1646                    .expect("blocking task")
1647                    .expect("writer thread did not exit within 5s")
1648                    .expect("writer thread panicked");
1649                }
1650            });
1651        }
1652    }
1653
1654    fn rt() -> tokio::runtime::Runtime {
1655        tokio::runtime::Builder::new_multi_thread()
1656            .worker_threads(2)
1657            .enable_all()
1658            .build()
1659            .unwrap()
1660    }
1661
1662    /// Issue one HTTP request through the router and capture status +
1663    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
1664    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
1665    async fn call(
1666        router: axum::Router,
1667        method: &str,
1668        uri: &str,
1669        body: Option<Value>,
1670    ) -> (StatusCode, Value) {
1671        call_with_auth(router, method, uri, body, None).await
1672    }
1673
1674    async fn call_with_auth(
1675        router: axum::Router,
1676        method: &str,
1677        uri: &str,
1678        body: Option<Value>,
1679        auth: Option<&str>,
1680    ) -> (StatusCode, Value) {
1681        let mut req_builder = Request::builder()
1682            .method(method)
1683            .uri(uri)
1684            .header("content-type", "application/json");
1685        if let Some(a) = auth {
1686            req_builder = req_builder.header("authorization", a);
1687        }
1688        let req = if let Some(b) = body {
1689            let bytes = serde_json::to_vec(&b).unwrap();
1690            req_builder.body(Body::from(bytes)).unwrap()
1691        } else {
1692            req_builder = req_builder.header("content-length", "0");
1693            req_builder.body(Body::empty()).unwrap()
1694        };
1695        let resp = router.oneshot(req).await.expect("oneshot");
1696        let status = resp.status();
1697        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1698        let v: Value = if body_bytes.is_empty() {
1699            Value::Null
1700        } else {
1701            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1702        };
1703        (status, v)
1704    }
1705
1706    #[test]
1707    fn health_returns_ok() {
1708        let runtime = rt();
1709        let h = Harness::new(&runtime);
1710        let r = h.router.clone();
1711        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1712        assert_eq!(status, StatusCode::OK);
1713        h.shutdown(&runtime);
1714    }
1715
1716    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
1717    /// the four `memory.*` endpoints + their request/response schemas.
1718    /// Acts as a drift detector: if a future commit adds/removes a route
1719    /// without updating `openapi_spec`, this test fails loudly.
1720    #[test]
1721    fn openapi_json_describes_all_endpoints() {
1722        let runtime = rt();
1723        let h = Harness::new(&runtime);
1724        let r = h.router.clone();
1725        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1726        assert_eq!(status, StatusCode::OK);
1727        assert!(spec.is_object(), "openapi.json must be a JSON object");
1728
1729        // Top-level shape per OpenAPI 3.1.
1730        assert!(
1731            spec.get("openapi")
1732                .and_then(|v| v.as_str())
1733                .is_some_and(|s| s.starts_with("3.")),
1734            "missing or wrong openapi version: {spec}"
1735        );
1736        assert!(spec.pointer("/info/title").is_some());
1737        assert!(spec.pointer("/info/version").is_some());
1738
1739        // Every route the router serves must be documented.
1740        let paths = spec
1741            .get("paths")
1742            .and_then(|v| v.as_object())
1743            .expect("paths must be an object");
1744        for expected in [
1745            "/health",
1746            "/openapi.json",
1747            "/memory",
1748            "/memory/search",
1749            "/memory/consolidate",
1750            "/memory/{id}",
1751            // Path 1 derived-layer endpoints (v0.4.0+):
1752            "/memory/themes",
1753            "/memory/facts_about",
1754            "/memory/contradictions",
1755            // v0.5.0 Priority 3:
1756            "/memory/clusters/{cluster_id}",
1757            // v0.7.0 P6 — document operations:
1758            "/memory/documents",
1759            "/memory/documents/search",
1760            "/memory/documents/{id}",
1761        ] {
1762            assert!(
1763                paths.contains_key(expected),
1764                "openapi paths missing {expected}: {paths:?}"
1765            );
1766        }
1767
1768        // Method coverage on /memory/documents: must document both POST
1769        // (ingest) and GET (list).
1770        let docs = paths.get("/memory/documents").expect("/memory/documents");
1771        assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
1772        assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
1773
1774        // Method coverage on /memory/documents/{id}: must document both
1775        // GET (inspect) and DELETE (forget).
1776        let docid = paths
1777            .get("/memory/documents/{id}")
1778            .expect("/memory/documents/{id}");
1779        assert!(
1780            docid.get("get").is_some(),
1781            "GET /memory/documents/{{id}} undocumented"
1782        );
1783        assert!(
1784            docid.get("delete").is_some(),
1785            "DELETE /memory/documents/{{id}} undocumented"
1786        );
1787
1788        // Method coverage on /memory/{id}: must document both GET (inspect)
1789        // and DELETE (forget).
1790        let memid = paths.get("/memory/{id}").expect("memory/{id}");
1791        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1792        assert!(
1793            memid.get("delete").is_some(),
1794            "DELETE /memory/{{id}} undocumented"
1795        );
1796
1797        // Component schemas referenced from paths must be defined.
1798        for schema_name in [
1799            "RememberRequest",
1800            "RememberResponse",
1801            "RecallRequest",
1802            "RecallResult",
1803            "EpisodeRecord",
1804            "ApiError",
1805            "ConsolidationScope",
1806            "ConsolidationReport",
1807            // Path 1 derived-layer schemas (v0.4.0+):
1808            "ThemeHit",
1809            "FactHit",
1810            "ContradictionHit",
1811            // v0.5.0 Priority 3:
1812            "ClusterRecord",
1813            // v0.7.0 P6 — document schemas:
1814            "IngestDocumentRequest",
1815            "IngestReport",
1816            "ForgetDocumentReport",
1817            "SearchDocsRequest",
1818            "DocSearchHit",
1819            "DocumentInspectResult",
1820            "DocumentSummary",
1821        ] {
1822            let ptr = format!("/components/schemas/{schema_name}");
1823            assert!(
1824                spec.pointer(&ptr).is_some(),
1825                "component schema {schema_name} missing"
1826            );
1827        }
1828
1829        // bearerAuth security scheme is declared (LAN deployments need it).
1830        assert!(
1831            spec.pointer("/components/securitySchemes/bearerAuth")
1832                .is_some(),
1833            "bearerAuth security scheme missing"
1834        );
1835
1836        h.shutdown(&runtime);
1837    }
1838
1839    /// `/openapi.json` must remain unauthenticated even when bearer auth
1840    /// is enabled — the spec describes the API shape, not secrets, and
1841    /// codegen tooling shouldn't need a credential to fetch it.
1842    #[test]
1843    fn openapi_json_is_exempt_from_bearer_auth() {
1844        let runtime = rt();
1845        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1846        let r = h.router.clone();
1847        // No Authorization header → still 200 for /openapi.json.
1848        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1849        assert_eq!(status, StatusCode::OK);
1850        h.shutdown(&runtime);
1851    }
1852
1853    #[test]
1854    fn remember_returns_memory_id() {
1855        let runtime = rt();
1856        let h = Harness::new(&runtime);
1857        let r = h.router.clone();
1858        let (status, body) = runtime.block_on(call(
1859            r,
1860            "POST",
1861            "/memory",
1862            Some(json!({ "content": "http harness test" })),
1863        ));
1864        assert_eq!(status, StatusCode::OK);
1865        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1866        assert_eq!(mid.len(), 36, "uuid length");
1867        h.shutdown(&runtime);
1868    }
1869
1870    #[test]
1871    fn empty_content_returns_400() {
1872        let runtime = rt();
1873        let h = Harness::new(&runtime);
1874        let r = h.router.clone();
1875        let (status, body) =
1876            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1877        assert_eq!(status, StatusCode::BAD_REQUEST);
1878        assert!(
1879            body.get("error")
1880                .and_then(|e| e.as_str())
1881                .map(|s| s.contains("must not be empty"))
1882                .unwrap_or(false),
1883            "got: {body}"
1884        );
1885        h.shutdown(&runtime);
1886    }
1887
1888    #[test]
1889    fn empty_query_returns_400() {
1890        let runtime = rt();
1891        let h = Harness::new(&runtime);
1892        let r = h.router.clone();
1893        let (status, body) = runtime.block_on(call(
1894            r,
1895            "POST",
1896            "/memory/search",
1897            Some(json!({ "query": "" })),
1898        ));
1899        assert_eq!(status, StatusCode::BAD_REQUEST);
1900        assert!(
1901            body.get("error")
1902                .and_then(|e| e.as_str())
1903                .map(|s| s.contains("must not be empty"))
1904                .unwrap_or(false),
1905            "got: {body}"
1906        );
1907        h.shutdown(&runtime);
1908    }
1909
1910    #[test]
1911    fn inspect_unknown_returns_404() {
1912        let runtime = rt();
1913        let h = Harness::new(&runtime);
1914        let r = h.router.clone();
1915        let (status, body) = runtime.block_on(call(
1916            r,
1917            "GET",
1918            "/memory/00000000-0000-7000-8000-000000000000",
1919            None,
1920        ));
1921        assert_eq!(status, StatusCode::NOT_FOUND);
1922        assert!(body.get("error").is_some(), "got: {body}");
1923        h.shutdown(&runtime);
1924    }
1925
1926    #[test]
1927    fn inspect_invalid_id_returns_400() {
1928        let runtime = rt();
1929        let h = Harness::new(&runtime);
1930        let r = h.router.clone();
1931        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1932        assert_eq!(status, StatusCode::BAD_REQUEST);
1933        h.shutdown(&runtime);
1934    }
1935
1936    #[test]
1937    fn forget_unknown_returns_404() {
1938        let runtime = rt();
1939        let h = Harness::new(&runtime);
1940        let r = h.router.clone();
1941        let (status, _body) = runtime.block_on(call(
1942            r,
1943            "DELETE",
1944            "/memory/00000000-0000-7000-8000-000000000000",
1945            None,
1946        ));
1947        assert_eq!(status, StatusCode::NOT_FOUND);
1948        h.shutdown(&runtime);
1949    }
1950
1951    /// `POST /memory/consolidate` runs the cluster pass and returns
1952    /// the report as JSON. With an empty body, `ConsolidationScope`
1953    /// defaults to unbounded; with a non-empty body, the
1954    /// `window_days` field is honored. The Harness's writer is
1955    /// spawned without a Steward, so `abstractions_built` stays 0
1956    /// even when `clusters_built` is nonzero — same posture as the
1957    /// daemon today.
1958    #[test]
1959    fn consolidate_endpoint_returns_report() {
1960        let runtime = rt();
1961        let h = Harness::new(&runtime);
1962        let r = h.router.clone();
1963        runtime.block_on(async move {
1964            // Empty DB → all-zero report; structural assertion only.
1965            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1966            assert_eq!(status, StatusCode::OK);
1967            for field in [
1968                "episodes_seen",
1969                "clusters_built",
1970                "episodes_clustered",
1971                "abstractions_built",
1972                "triples_built",
1973                "contradictions_found",
1974            ] {
1975                assert!(
1976                    body.get(field).and_then(|v| v.as_u64()).is_some(),
1977                    "missing field {field}: {body}"
1978                );
1979            }
1980            assert_eq!(body["episodes_seen"], 0);
1981            assert_eq!(body["clusters_built"], 0);
1982
1983            // Non-empty body with window_days → still 200; unmistakable
1984            // shape round-trips through ConsolidationScope's serde.
1985            let (status2, _body2) = call(
1986                r,
1987                "POST",
1988                "/memory/consolidate",
1989                Some(json!({ "window_days": 7 })),
1990            )
1991            .await;
1992            assert_eq!(status2, StatusCode::OK);
1993        });
1994        h.shutdown(&runtime);
1995    }
1996
1997    #[test]
1998    fn auth_required_routes_reject_missing_token() {
1999        let runtime = rt();
2000        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
2001        let r = h.router.clone();
2002        runtime.block_on(async move {
2003            // No Authorization header → 401.
2004            let (status, _body) = call(
2005                r.clone(),
2006                "POST",
2007                "/memory",
2008                Some(json!({ "content": "x" })),
2009            )
2010            .await;
2011            assert_eq!(status, StatusCode::UNAUTHORIZED);
2012
2013            // Wrong token → 401.
2014            let (status, _body) = call_with_auth(
2015                r.clone(),
2016                "POST",
2017                "/memory",
2018                Some(json!({ "content": "x" })),
2019                Some("Bearer wrong-token"),
2020            )
2021            .await;
2022            assert_eq!(status, StatusCode::UNAUTHORIZED);
2023
2024            // Correct token → handler runs (200).
2025            let (status, body) = call_with_auth(
2026                r.clone(),
2027                "POST",
2028                "/memory",
2029                Some(json!({ "content": "authed" })),
2030                Some("Bearer secret-xyz"),
2031            )
2032            .await;
2033            assert_eq!(status, StatusCode::OK);
2034            assert!(body.get("memory_id").is_some());
2035        });
2036        h.shutdown(&runtime);
2037    }
2038
2039    #[test]
2040    fn health_endpoint_does_not_require_auth() {
2041        let runtime = rt();
2042        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2043        let r = h.router.clone();
2044        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
2045        // Liveness probes should work without credentials.
2046        assert_eq!(status, StatusCode::OK);
2047        h.shutdown(&runtime);
2048    }
2049
2050    #[test]
2051    fn auth_response_includes_www_authenticate_header() {
2052        // Verify the WWW-Authenticate hint that lets a well-behaved
2053        // client know it's a bearer-auth scheme. We check via raw
2054        // request → response (oneshot returns Response, but our
2055        // call() helper drops the headers; build the request manually).
2056        let runtime = rt();
2057        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2058        let r = h.router.clone();
2059        runtime.block_on(async move {
2060            let req = Request::builder()
2061                .method("POST")
2062                .uri("/memory")
2063                .header("content-type", "application/json")
2064                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
2065                .unwrap();
2066            let resp = r.oneshot(req).await.unwrap();
2067            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2068            let www = resp
2069                .headers()
2070                .get("www-authenticate")
2071                .and_then(|v| v.to_str().ok())
2072                .unwrap_or("");
2073            assert!(
2074                www.starts_with("Bearer"),
2075                "expected WWW-Authenticate: Bearer..., got: {www}"
2076            );
2077        });
2078        h.shutdown(&runtime);
2079    }
2080
2081    // ---------------------------------------------------------------------
2082    // v0.8.0 P3: OIDC end-to-end. Spin up a fake IdP (wiremock) that
2083    // serves an OIDC discovery doc + JWKS, mint a token claiming
2084    // `solo_tenant = "default"`, and verify it routes through the
2085    // middleware + TenantExtractor + handler.
2086    // ---------------------------------------------------------------------
2087
2088    fn base64_url_for_test(bytes: &[u8]) -> String {
2089        use base64::Engine;
2090        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2091    }
2092
2093    /// Spin up a single-purpose fake OIDC IdP for these tests. Returns
2094    /// (mock_server, discovery_url, secret, kid).
2095    async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
2096        use wiremock::matchers::{method, path};
2097        use wiremock::{Mock, MockServer, ResponseTemplate};
2098        let server = MockServer::start().await;
2099        let secret = b"http-test-secret-for-hmac-fixture".to_vec();
2100        let kid = "http-test-kid";
2101        let discovery = serde_json::json!({
2102            "issuer": server.uri(),
2103            "jwks_uri": format!("{}/jwks", server.uri()),
2104        });
2105        Mock::given(method("GET"))
2106            .and(path("/.well-known/openid-configuration"))
2107            .respond_with(ResponseTemplate::new(200).set_body_json(discovery))
2108            .mount(&server)
2109            .await;
2110        let jwks = serde_json::json!({
2111            "keys": [
2112                {
2113                    "kty": "oct",
2114                    "kid": kid,
2115                    "alg": "HS256",
2116                    "k": base64_url_for_test(&secret),
2117                }
2118            ]
2119        });
2120        Mock::given(method("GET"))
2121            .and(path("/jwks"))
2122            .respond_with(ResponseTemplate::new(200).set_body_json(jwks))
2123            .mount(&server)
2124            .await;
2125        let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
2126        (server, discovery_url, secret, kid)
2127    }
2128
2129    fn mint_idp_token(
2130        server_uri: &str,
2131        kid: &str,
2132        secret: &[u8],
2133        tenant_claim: &str,
2134        audience: &str,
2135    ) -> String {
2136        use jsonwebtoken::{Algorithm, EncodingKey, Header};
2137        let mut header = Header::new(Algorithm::HS256);
2138        header.kid = Some(kid.to_string());
2139        let now = std::time::SystemTime::now()
2140            .duration_since(std::time::UNIX_EPOCH)
2141            .unwrap()
2142            .as_secs();
2143        let claims = serde_json::json!({
2144            "iss": server_uri,
2145            "sub": "test-user-1",
2146            "aud": audience,
2147            "exp": now + 600,
2148            "iat": now,
2149            "solo_tenant": tenant_claim,
2150        });
2151        jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
2152            .expect("mint token")
2153    }
2154
2155    #[test]
2156    fn http_oidc_accept_resolves_to_tenant_from_claim() {
2157        let runtime = rt();
2158        let (fake_server, discovery_url, secret, kid) =
2159            runtime.block_on(async { spin_fake_idp().await });
2160        let server_uri = fake_server.uri();
2161        // Keep the wiremock server alive for the duration of this test.
2162        let _server_guard = fake_server;
2163
2164        let auth = crate::auth::AuthConfig::Oidc {
2165            discovery_url,
2166            audience: "test-audience".to_string(),
2167            tenant_claim_name: "solo_tenant".to_string(),
2168        };
2169        let h = Harness::new_with_auth_config(&runtime, Some(auth));
2170        let r = h.router.clone();
2171
2172        // Mint a token claiming the harness's default tenant.
2173        let token = mint_idp_token(
2174            &server_uri,
2175            kid,
2176            &secret,
2177            "default",
2178            "test-audience",
2179        );
2180
2181        runtime.block_on(async move {
2182            // POST /memory with a valid OIDC token → handler runs, returns memory_id.
2183            let (status, body) = call_with_auth(
2184                r.clone(),
2185                "POST",
2186                "/memory",
2187                Some(json!({ "content": "oidc-routed content" })),
2188                Some(&format!("Bearer {token}")),
2189            )
2190            .await;
2191            assert_eq!(status, StatusCode::OK, "got body: {body}");
2192            assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
2193        });
2194        h.shutdown(&runtime);
2195    }
2196
2197    #[test]
2198    fn http_oidc_reject_missing_token_returns_401() {
2199        let runtime = rt();
2200        let (fake_server, discovery_url, _secret, _kid) =
2201            runtime.block_on(async { spin_fake_idp().await });
2202        let _server_guard = fake_server;
2203        let auth = crate::auth::AuthConfig::Oidc {
2204            discovery_url,
2205            audience: "test-audience".to_string(),
2206            tenant_claim_name: "solo_tenant".to_string(),
2207        };
2208        let h = Harness::new_with_auth_config(&runtime, Some(auth));
2209        let r = h.router.clone();
2210        runtime.block_on(async move {
2211            // No Authorization header.
2212            let (status, _body) =
2213                call(r.clone(), "POST", "/memory", Some(json!({ "content": "x" }))).await;
2214            assert_eq!(status, StatusCode::UNAUTHORIZED);
2215
2216            // Garbage token → 401 (invalid signature / not a JWT).
2217            let (status, _body) = call_with_auth(
2218                r.clone(),
2219                "POST",
2220                "/memory",
2221                Some(json!({ "content": "x" })),
2222                Some("Bearer not-a-real-jwt"),
2223            )
2224            .await;
2225            assert_eq!(status, StatusCode::UNAUTHORIZED);
2226        });
2227        h.shutdown(&runtime);
2228    }
2229
2230    #[test]
2231    fn full_remember_recall_inspect_forget_round_trip() {
2232        let runtime = rt();
2233        let h = Harness::new(&runtime);
2234        let r = h.router.clone();
2235        runtime.block_on(async move {
2236            // POST /memory
2237            let (status, body) = call(
2238                r.clone(),
2239                "POST",
2240                "/memory",
2241                Some(json!({ "content": "round-trip content" })),
2242            )
2243            .await;
2244            assert_eq!(status, StatusCode::OK);
2245            let mid = body
2246                .get("memory_id")
2247                .and_then(|v| v.as_str())
2248                .unwrap()
2249                .to_string();
2250
2251            // POST /memory/search — exact-match (StubEmbedder) returns the row.
2252            let (status, body) = call(
2253                r.clone(),
2254                "POST",
2255                "/memory/search",
2256                Some(json!({ "query": "round-trip content", "limit": 5 })),
2257            )
2258            .await;
2259            assert_eq!(status, StatusCode::OK);
2260            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2261            assert!(
2262                hits.iter()
2263                    .any(|h| h.get("content").and_then(|c| c.as_str())
2264                        == Some("round-trip content")),
2265                "expected hit with content; got: {body}"
2266            );
2267
2268            // GET /memory/{id}
2269            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2270            assert_eq!(status, StatusCode::OK);
2271            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
2272
2273            // DELETE /memory/{id}
2274            let (status, _body) =
2275                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
2276            assert_eq!(status, StatusCode::NO_CONTENT);
2277
2278            // GET again — still readable but status='forgotten'
2279            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2280            assert_eq!(status, StatusCode::OK);
2281            assert_eq!(
2282                body.get("status").and_then(|v| v.as_str()),
2283                Some("forgotten")
2284            );
2285
2286            // POST /memory/search — forgotten row excluded.
2287            let (status, body) = call(
2288                r.clone(),
2289                "POST",
2290                "/memory/search",
2291                Some(json!({ "query": "round-trip content", "limit": 5 })),
2292            )
2293            .await;
2294            assert_eq!(status, StatusCode::OK);
2295            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2296            assert!(
2297                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
2298                    != Some(mid.as_str())),
2299                "forgotten row should be excluded from recall: {body}"
2300            );
2301        });
2302        h.shutdown(&runtime);
2303    }
2304
2305    // Path 1 derived-layer endpoint tests (v0.4.0+). Wire-path only —
2306    // the actual content correctness is covered by solo-query::derived's
2307    // own tests (Sub-task A). These verify the HTTP shape: GET routing,
2308    // Query-string param parsing, JSON-array response body, validation
2309    // 400s for invalid inputs.
2310
2311    #[test]
2312    fn themes_endpoint_returns_empty_array_on_empty_db() {
2313        let runtime = rt();
2314        let h = Harness::new(&runtime);
2315        let r = h.router.clone();
2316        let (status, body) =
2317            runtime.block_on(call(r, "GET", "/memory/themes", None));
2318        assert_eq!(status, StatusCode::OK);
2319        assert!(body.is_array(), "expected array, got {body}");
2320        assert_eq!(body.as_array().unwrap().len(), 0);
2321        h.shutdown(&runtime);
2322    }
2323
2324    #[test]
2325    fn themes_endpoint_passes_through_query_params() {
2326        let runtime = rt();
2327        let h = Harness::new(&runtime);
2328        let r = h.router.clone();
2329        let (status, body) = runtime.block_on(call(
2330            r,
2331            "GET",
2332            "/memory/themes?window_days=7&limit=20",
2333            None,
2334        ));
2335        assert_eq!(status, StatusCode::OK);
2336        assert!(body.is_array(), "expected array, got {body}");
2337        h.shutdown(&runtime);
2338    }
2339
2340    #[test]
2341    fn facts_about_endpoint_requires_subject() {
2342        let runtime = rt();
2343        let h = Harness::new(&runtime);
2344        let r = h.router.clone();
2345        // Missing subject — axum's Query extractor 422 (Unprocessable
2346        // Entity) on missing required field; some axum versions
2347        // surface as 400. Accept either.
2348        let (status, _body) =
2349            runtime.block_on(call(r, "GET", "/memory/facts_about", None));
2350        assert!(
2351            status == StatusCode::BAD_REQUEST
2352                || status == StatusCode::UNPROCESSABLE_ENTITY,
2353            "expected 400 or 422 for missing subject, got {status}"
2354        );
2355        h.shutdown(&runtime);
2356    }
2357
2358    #[test]
2359    fn facts_about_endpoint_rejects_blank_subject() {
2360        let runtime = rt();
2361        let h = Harness::new(&runtime);
2362        let r = h.router.clone();
2363        // Whitespace-only subject reaches the handler then trips its
2364        // own validation → ApiError::bad_request → 400.
2365        let (status, body) = runtime.block_on(call(
2366            r,
2367            "GET",
2368            "/memory/facts_about?subject=%20%20",
2369            None,
2370        ));
2371        assert_eq!(status, StatusCode::BAD_REQUEST);
2372        assert!(
2373            body.get("error")
2374                .and_then(|v| v.as_str())
2375                .is_some_and(|s| s.contains("subject")),
2376            "expected error mentioning subject, got {body}"
2377        );
2378        h.shutdown(&runtime);
2379    }
2380
2381    #[test]
2382    fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
2383        let runtime = rt();
2384        let h = Harness::new(&runtime);
2385        let r = h.router.clone();
2386        let (status, body) = runtime.block_on(call(
2387            r,
2388            "GET",
2389            "/memory/facts_about?subject=NobodyKnows",
2390            None,
2391        ));
2392        assert_eq!(status, StatusCode::OK);
2393        assert_eq!(body.as_array().unwrap().len(), 0);
2394        h.shutdown(&runtime);
2395    }
2396
2397    #[test]
2398    fn facts_about_endpoint_parses_include_as_object_query_param() {
2399        // v0.5.1 P8: `?include_as_object=true` must parse cleanly
2400        // through the `Query<FactsAboutQuery>` extractor. If the
2401        // struct field is missing or wrongly typed, axum returns
2402        // 400/422 before reaching the handler. We don't seed
2403        // triples; we only need the request to reach the handler
2404        // and produce a normal 200 + empty array. Mirrors
2405        // `inspect_cluster_endpoint_passes_full_content_query_param`.
2406        let runtime = rt();
2407        let h = Harness::new(&runtime);
2408        let r = h.router.clone();
2409        let (status, body) = runtime.block_on(call(
2410            r,
2411            "GET",
2412            "/memory/facts_about?subject=Maya&include_as_object=true",
2413            None,
2414        ));
2415        assert_eq!(
2416            status,
2417            StatusCode::OK,
2418            "expected 200 with include_as_object query param, got {status}"
2419        );
2420        assert!(body.is_array());
2421        h.shutdown(&runtime);
2422    }
2423
2424    #[test]
2425    fn inspect_cluster_endpoint_unknown_id_returns_404() {
2426        // Maps `Error::NotFound` from `solo_query::inspect_cluster`
2427        // through `ApiError::from` → 404. Mirrors the unknown-memory
2428        // case for `GET /memory/{id}`.
2429        let runtime = rt();
2430        let h = Harness::new(&runtime);
2431        let r = h.router.clone();
2432        let (status, body) = runtime.block_on(call(
2433            r,
2434            "GET",
2435            "/memory/clusters/no-such-cluster",
2436            None,
2437        ));
2438        assert_eq!(status, StatusCode::NOT_FOUND);
2439        assert!(
2440            body.get("error")
2441                .and_then(|v| v.as_str())
2442                .is_some_and(|s| s.contains("no-such-cluster")),
2443            "expected error mentioning cluster id, got {body}"
2444        );
2445        h.shutdown(&runtime);
2446    }
2447
2448    #[test]
2449    fn inspect_cluster_endpoint_passes_full_content_query_param() {
2450        // Even with no matching cluster (→ 404), the request must
2451        // reach the handler — proves the `?full_content=true` query
2452        // string parses cleanly (Query<InspectClusterQuery>::default
2453        // path didn't choke). If we accidentally fail at the extractor
2454        // we'd get a 400/422, not the expected 404.
2455        let runtime = rt();
2456        let h = Harness::new(&runtime);
2457        let r = h.router.clone();
2458        let (status, _body) = runtime.block_on(call(
2459            r,
2460            "GET",
2461            "/memory/clusters/missing?full_content=true",
2462            None,
2463        ));
2464        assert_eq!(status, StatusCode::NOT_FOUND);
2465        h.shutdown(&runtime);
2466    }
2467
2468    #[test]
2469    fn contradictions_endpoint_returns_empty_array_on_empty_db() {
2470        let runtime = rt();
2471        let h = Harness::new(&runtime);
2472        let r = h.router.clone();
2473        let (status, body) = runtime.block_on(call(
2474            r,
2475            "GET",
2476            "/memory/contradictions",
2477            None,
2478        ));
2479        assert_eq!(status, StatusCode::OK);
2480        assert!(body.is_array());
2481        assert_eq!(body.as_array().unwrap().len(), 0);
2482        h.shutdown(&runtime);
2483    }
2484
2485    #[test]
2486    fn derived_endpoints_require_bearer_when_auth_enabled() {
2487        let runtime = rt();
2488        let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
2489        // Each of the three new endpoints should reject missing token.
2490        // Per the existing tests' shutdown-timing comment: don't hold a
2491        // long-lived router clone across multiple iterations — drop the
2492        // clone before each subsequent oneshot, and don't keep a `let r =
2493        // h.router.clone()` alive across h.shutdown(). Re-clone per
2494        // iteration; the per-call clone is consumed by oneshot.
2495        for path in [
2496            "/memory/themes",
2497            "/memory/facts_about?subject=Sam",
2498            "/memory/contradictions",
2499            "/memory/clusters/any-id",
2500        ] {
2501            let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
2502            assert_eq!(
2503                status,
2504                StatusCode::UNAUTHORIZED,
2505                "{path} should 401 without token"
2506            );
2507        }
2508        h.shutdown(&runtime);
2509    }
2510
2511    // ---- Document endpoints (v0.7.0 P6) ----
2512    //
2513    // Wire-path coverage. The `Harness` here uses
2514    // `WriterActor::spawn_full` without an embedder — same shape as the
2515    // existing handler tests. Ingest/search would fail at the writer
2516    // boundary with "writer has no embedder", but every other path
2517    // (404s, malformed ids, route shape, bearer auth gating, OpenAPI
2518    // documentation) is exercisable. Real end-to-end ingest→search
2519    // round-trip lives in `mcp_smoke.rs` where a real subprocess runs
2520    // with a fully-wired writer.
2521
2522    #[test]
2523    fn list_documents_endpoint_returns_empty_array_on_empty_db() {
2524        let runtime = rt();
2525        let h = Harness::new(&runtime);
2526        let r = h.router.clone();
2527        let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
2528        assert_eq!(status, StatusCode::OK);
2529        assert!(body.is_array(), "expected array, got {body}");
2530        assert_eq!(body.as_array().unwrap().len(), 0);
2531        h.shutdown(&runtime);
2532    }
2533
2534    #[test]
2535    fn list_documents_endpoint_parses_query_params() {
2536        let runtime = rt();
2537        let h = Harness::new(&runtime);
2538        let r = h.router.clone();
2539        let (status, body) = runtime.block_on(call(
2540            r,
2541            "GET",
2542            "/memory/documents?limit=5&offset=0&include_forgotten=true",
2543            None,
2544        ));
2545        assert_eq!(status, StatusCode::OK);
2546        assert!(body.is_array());
2547        h.shutdown(&runtime);
2548    }
2549
2550    #[test]
2551    fn ingest_document_endpoint_rejects_empty_path() {
2552        let runtime = rt();
2553        let h = Harness::new(&runtime);
2554        let r = h.router.clone();
2555        let (status, body) = runtime.block_on(call(
2556            r,
2557            "POST",
2558            "/memory/documents",
2559            Some(json!({ "path": "" })),
2560        ));
2561        assert_eq!(status, StatusCode::BAD_REQUEST);
2562        assert!(
2563            body.get("error")
2564                .and_then(|v| v.as_str())
2565                .is_some_and(|s| s.contains("path")),
2566            "expected error mentioning path, got {body}"
2567        );
2568        h.shutdown(&runtime);
2569    }
2570
2571    #[test]
2572    fn search_docs_endpoint_rejects_empty_query() {
2573        let runtime = rt();
2574        let h = Harness::new(&runtime);
2575        let r = h.router.clone();
2576        let (status, body) = runtime.block_on(call(
2577            r,
2578            "POST",
2579            "/memory/documents/search",
2580            Some(json!({ "query": "   " })),
2581        ));
2582        assert_eq!(status, StatusCode::BAD_REQUEST);
2583        assert!(
2584            body.get("error")
2585                .and_then(|v| v.as_str())
2586                .is_some_and(|s| s.contains("must not be empty")
2587                    || s.contains("doc_search")),
2588            "expected error mentioning empty query, got {body}"
2589        );
2590        h.shutdown(&runtime);
2591    }
2592
2593    #[test]
2594    fn inspect_document_endpoint_unknown_id_returns_404() {
2595        let runtime = rt();
2596        let h = Harness::new(&runtime);
2597        let r = h.router.clone();
2598        let (status, body) = runtime.block_on(call(
2599            r,
2600            "GET",
2601            "/memory/documents/00000000-0000-7000-8000-000000000000",
2602            None,
2603        ));
2604        assert_eq!(status, StatusCode::NOT_FOUND);
2605        assert!(body.get("error").is_some(), "got: {body}");
2606        h.shutdown(&runtime);
2607    }
2608
2609    #[test]
2610    fn inspect_document_endpoint_rejects_malformed_id() {
2611        let runtime = rt();
2612        let h = Harness::new(&runtime);
2613        let r = h.router.clone();
2614        let (status, _body) =
2615            runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
2616        assert_eq!(status, StatusCode::BAD_REQUEST);
2617        h.shutdown(&runtime);
2618    }
2619
2620    #[test]
2621    fn forget_document_endpoint_unknown_id_returns_404() {
2622        // Valid UUID format; no row exists → writer's `forget_document`
2623        // returns Error::NotFound → mapped to 404 by `ApiError::from`.
2624        let runtime = rt();
2625        let h = Harness::new(&runtime);
2626        let r = h.router.clone();
2627        let (status, _body) = runtime.block_on(call(
2628            r,
2629            "DELETE",
2630            "/memory/documents/00000000-0000-7000-8000-000000000000",
2631            None,
2632        ));
2633        assert_eq!(status, StatusCode::NOT_FOUND);
2634        h.shutdown(&runtime);
2635    }
2636
2637    #[test]
2638    fn forget_document_endpoint_rejects_malformed_id() {
2639        let runtime = rt();
2640        let h = Harness::new(&runtime);
2641        let r = h.router.clone();
2642        let (status, _body) =
2643            runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
2644        assert_eq!(status, StatusCode::BAD_REQUEST);
2645        h.shutdown(&runtime);
2646    }
2647
2648    #[test]
2649    fn document_endpoints_require_bearer_when_auth_enabled() {
2650        // All five doc endpoints sit behind the same authed Router and
2651        // must 401 without the bearer token. Mirrors
2652        // `derived_endpoints_require_bearer_when_auth_enabled`.
2653        let runtime = rt();
2654        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2655        let cases: &[(&str, &str, Option<Value>)] = &[
2656            ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
2657            ("GET", "/memory/documents", None),
2658            (
2659                "POST",
2660                "/memory/documents/search",
2661                Some(json!({ "query": "x" })),
2662            ),
2663            (
2664                "GET",
2665                "/memory/documents/00000000-0000-7000-8000-000000000000",
2666                None,
2667            ),
2668            (
2669                "DELETE",
2670                "/memory/documents/00000000-0000-7000-8000-000000000000",
2671                None,
2672            ),
2673        ];
2674        for (method, path, body) in cases {
2675            let (status, _) =
2676                runtime.block_on(call(h.router.clone(), method, path, body.clone()));
2677            assert_eq!(
2678                status,
2679                StatusCode::UNAUTHORIZED,
2680                "{method} {path} should 401 without token"
2681            );
2682        }
2683        h.shutdown(&runtime);
2684    }
2685
2686    #[test]
2687    fn document_endpoints_accept_correct_bearer_token() {
2688        // Sanity check: with the right token, the same five endpoints
2689        // pass auth and reach the handler. We only assert that the
2690        // status code is NOT 401 — exact downstream behaviour depends
2691        // on the harness (no embedder → ingest/search would 500; empty
2692        // DB → list/inspect/forget return 200/404).
2693        let runtime = rt();
2694        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2695        runtime.block_on(async {
2696            // GET /memory/documents → 200 + empty array (auth passes).
2697            let (status, _) = call_with_auth(
2698                h.router.clone(),
2699                "GET",
2700                "/memory/documents",
2701                None,
2702                Some("Bearer doc-secret"),
2703            )
2704            .await;
2705            assert_eq!(status, StatusCode::OK);
2706
2707            // GET /memory/documents/<unknown> → 404 (auth passes).
2708            let (status, _) = call_with_auth(
2709                h.router.clone(),
2710                "GET",
2711                "/memory/documents/00000000-0000-7000-8000-000000000000",
2712                None,
2713                Some("Bearer doc-secret"),
2714            )
2715            .await;
2716            assert_eq!(status, StatusCode::NOT_FOUND);
2717        });
2718        h.shutdown(&runtime);
2719    }
2720
2721    // ---------------------------------------------------------------------
2722    // v0.8.0 P2: tenant header extractor tests
2723    // ---------------------------------------------------------------------
2724
2725    /// `X-Solo-Tenant: default` resolves to the default tenant (which
2726    /// in the test harness is the only one wired in the registry).
2727    #[test]
2728    fn tenant_header_default_resolves() {
2729        let runtime = rt();
2730        let h = Harness::new(&runtime);
2731        let r = h.router.clone();
2732        let (status, _body) = runtime.block_on(async {
2733            let req = Request::builder()
2734                .method("GET")
2735                .uri("/memory/00000000-0000-7000-8000-000000000000")
2736                .header("x-solo-tenant", "default")
2737                .body(Body::empty())
2738                .unwrap();
2739            let resp = r.oneshot(req).await.expect("oneshot");
2740            let s = resp.status();
2741            let _b = resp.into_body().collect().await.unwrap().to_bytes();
2742            (s, _b)
2743        });
2744        // 404 because the id doesn't exist — but it's a routed 404 from
2745        // inspect_handler, not a 400 from a bad tenant header. That's
2746        // the proof point.
2747        assert_eq!(status, StatusCode::NOT_FOUND);
2748        h.shutdown(&runtime);
2749    }
2750
2751    /// `X-Solo-Tenant: UPPER` → 400 (invalid tenant id format).
2752    #[test]
2753    fn tenant_header_invalid_returns_400() {
2754        let runtime = rt();
2755        let h = Harness::new(&runtime);
2756        let r = h.router.clone();
2757        let (status, body) = runtime.block_on(async {
2758            let req = Request::builder()
2759                .method("GET")
2760                .uri("/memory/00000000-0000-7000-8000-000000000000")
2761                .header("x-solo-tenant", "UPPER")
2762                .body(Body::empty())
2763                .unwrap();
2764            let resp = r.oneshot(req).await.expect("oneshot");
2765            let s = resp.status();
2766            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2767            let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
2768            (s, v)
2769        });
2770        assert_eq!(status, StatusCode::BAD_REQUEST);
2771        let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
2772        assert!(
2773            msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
2774            "error must mention tenant/invalid: {msg}"
2775        );
2776        h.shutdown(&runtime);
2777    }
2778
2779    /// `X-Solo-Tenant: never-registered` → 404 (unknown tenant id).
2780    #[test]
2781    fn tenant_header_unknown_returns_404() {
2782        let runtime = rt();
2783        let h = Harness::new(&runtime);
2784        let r = h.router.clone();
2785        let (status, _body) = runtime.block_on(async {
2786            let req = Request::builder()
2787                .method("GET")
2788                .uri("/memory/00000000-0000-7000-8000-000000000000")
2789                .header("x-solo-tenant", "never-registered")
2790                .body(Body::empty())
2791                .unwrap();
2792            let resp = r.oneshot(req).await.expect("oneshot");
2793            let s = resp.status();
2794            let _b = resp.into_body().collect().await.unwrap().to_bytes();
2795            (s, _b)
2796        });
2797        assert_eq!(status, StatusCode::NOT_FOUND);
2798        h.shutdown(&runtime);
2799    }
2800
2801    /// No `X-Solo-Tenant` header → falls back to state.default_tenant.
2802    /// The reach-through to `inspect_handler` should produce the normal
2803    /// 404 for an unknown id rather than a tenant-routing error.
2804    #[test]
2805    fn tenant_header_missing_defaults_to_state_default_tenant() {
2806        let runtime = rt();
2807        let h = Harness::new(&runtime);
2808        let r = h.router.clone();
2809        let (status, _body) = runtime.block_on(async {
2810            let req = Request::builder()
2811                .method("GET")
2812                .uri("/memory/00000000-0000-7000-8000-000000000000")
2813                .body(Body::empty())
2814                .unwrap();
2815            let resp = r.oneshot(req).await.expect("oneshot");
2816            let s = resp.status();
2817            let _b = resp.into_body().collect().await.unwrap().to_bytes();
2818            (s, _b)
2819        });
2820        assert_eq!(status, StatusCode::NOT_FOUND);
2821        h.shutdown(&runtime);
2822    }
2823}
2824
2825#[cfg(test)]
2826mod cors_tests {
2827    use super::is_localhost_origin;
2828
2829    #[test]
2830    fn accepts_canonical_localhost_origins() {
2831        assert!(is_localhost_origin("http://localhost"));
2832        assert!(is_localhost_origin("http://localhost:3000"));
2833        assert!(is_localhost_origin("https://localhost:8443"));
2834        assert!(is_localhost_origin("http://127.0.0.1"));
2835        assert!(is_localhost_origin("http://127.0.0.1:5173"));
2836        assert!(is_localhost_origin("http://[::1]"));
2837        assert!(is_localhost_origin("http://[::1]:8080"));
2838    }
2839
2840    #[test]
2841    fn rejects_remote_origins() {
2842        assert!(!is_localhost_origin("http://example.com"));
2843        assert!(!is_localhost_origin("https://malicious.example"));
2844        assert!(!is_localhost_origin("http://192.168.1.5"));
2845        assert!(!is_localhost_origin("http://10.0.0.1"));
2846    }
2847
2848    #[test]
2849    fn rejects_dns_rebinding_tricks() {
2850        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
2851        // Origin header carries the public-DNS name. Rejecting these
2852        // closes the rebinding-via-Origin gap.
2853        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
2854        assert!(!is_localhost_origin("http://localhost.evil.com"));
2855        assert!(!is_localhost_origin("http://evil.localhost"));
2856    }
2857
2858    #[test]
2859    fn rejects_non_http_schemes() {
2860        assert!(!is_localhost_origin("file:///"));
2861        assert!(!is_localhost_origin("ws://localhost:3000"));
2862        assert!(!is_localhost_origin("javascript:alert(1)"));
2863    }
2864
2865    #[test]
2866    fn rejects_malformed() {
2867        assert!(!is_localhost_origin(""));
2868        assert!(!is_localhost_origin("localhost"));
2869        assert!(!is_localhost_origin("//localhost"));
2870    }
2871}
2872