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        }
1518    }
1519
1520    struct Harness {
1521        router: axum::Router,
1522        _tmp: tempfile::TempDir,
1523        write_handle_extra: Option<solo_storage::WriteHandle>,
1524        join: Option<std::thread::JoinHandle<()>>,
1525    }
1526
1527    impl Harness {
1528        fn new(runtime: &tokio::runtime::Runtime) -> Self {
1529            Self::new_with_auth(runtime, None)
1530        }
1531
1532        fn new_with_auth(
1533            runtime: &tokio::runtime::Runtime,
1534            bearer_token: Option<String>,
1535        ) -> Self {
1536            Self::new_with_auth_config(
1537                runtime,
1538                bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
1539            )
1540        }
1541
1542        fn new_with_auth_config(
1543            runtime: &tokio::runtime::Runtime,
1544            auth: Option<crate::auth::AuthConfig>,
1545        ) -> Self {
1546            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1547
1548            let tmp = tempfile::TempDir::new().unwrap();
1549            let dim = 16usize;
1550            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1551            let embedder: StdArc<dyn solo_core::Embedder> =
1552                StdArc::new(StubEmbedder::new("stub", "v1", dim));
1553            let path = tmp.path().join("test.db");
1554
1555            let embedder_id = {
1556                let conn = solo_storage::test_support::open_test_db_at(&path);
1557                get_or_insert_embedder_id(
1558                    &conn,
1559                    &EmbedderIdentity {
1560                        name: "stub".into(),
1561                        version: "v1".into(),
1562                        dim: dim as u32,
1563                        dtype: "f32".into(),
1564                    },
1565                )
1566                .unwrap()
1567            };
1568
1569            let conn = solo_storage::test_support::open_test_db_at(&path);
1570            let WriterSpawn { handle, join } = WriterActor::spawn_full(
1571                conn,
1572                hnsw.clone(),
1573                tmp.path().to_path_buf(),
1574                embedder_id,
1575            );
1576            let pool: ReaderPool =
1577                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1578
1579            // Build a TenantHandle from the assembled parts and wrap it
1580            // in a single-tenant test registry.
1581            let tenant_id = solo_core::TenantId::default_tenant();
1582            let tenant_handle = StdArc::new(
1583                TenantHandle::from_parts_for_tests(
1584                    tenant_id.clone(),
1585                    fake_config(dim as u32),
1586                    path.clone(),
1587                    tmp.path().to_path_buf(),
1588                    embedder_id,
1589                    hnsw,
1590                    embedder.clone(),
1591                    handle.clone(),
1592                    // The harness owns ANOTHER WriteHandle clone + the join.
1593                    // We give the TenantHandle a dummy join that immediately
1594                    // returns — it never gets joined because shutdown_all
1595                    // can't get exclusive Arc ownership when the harness
1596                    // also holds a writer clone.
1597                    std::thread::spawn(|| {}),
1598                    pool,
1599                ),
1600            );
1601
1602            // Suppress the auto-spawned dummy thread by letting it finish.
1603            // We DON'T put the real `join` into the TenantHandle because
1604            // we keep our own clone of `handle` for the shutdown path.
1605            let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1606            let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1607                tmp.path().to_path_buf(),
1608                key,
1609                embedder,
1610                tenant_handle,
1611            ));
1612
1613            let state = SoloHttpState {
1614                registry,
1615                default_tenant: tenant_id,
1616                user_aliases: Arc::new(Vec::new()),
1617            };
1618            let router = router_with_auth_config(state, auth);
1619            Harness {
1620                router,
1621                _tmp: tmp,
1622                write_handle_extra: Some(handle),
1623                join: Some(join),
1624            }
1625        }
1626
1627        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1628            let join = self.join.take();
1629            let extra = self.write_handle_extra.take();
1630            runtime.block_on(async move {
1631                drop(extra);
1632                drop(self.router); // drops state → drops pool inside runtime ctx
1633                drop(self._tmp);
1634                if let Some(join) = join {
1635                    let (tx, rx) = std::sync::mpsc::channel();
1636                    std::thread::spawn(move || {
1637                        let _ = tx.send(join.join());
1638                    });
1639                    tokio::task::spawn_blocking(move || {
1640                        rx.recv_timeout(std::time::Duration::from_secs(5))
1641                    })
1642                    .await
1643                    .expect("blocking task")
1644                    .expect("writer thread did not exit within 5s")
1645                    .expect("writer thread panicked");
1646                }
1647            });
1648        }
1649    }
1650
1651    fn rt() -> tokio::runtime::Runtime {
1652        tokio::runtime::Builder::new_multi_thread()
1653            .worker_threads(2)
1654            .enable_all()
1655            .build()
1656            .unwrap()
1657    }
1658
1659    /// Issue one HTTP request through the router and capture status +
1660    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
1661    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
1662    async fn call(
1663        router: axum::Router,
1664        method: &str,
1665        uri: &str,
1666        body: Option<Value>,
1667    ) -> (StatusCode, Value) {
1668        call_with_auth(router, method, uri, body, None).await
1669    }
1670
1671    async fn call_with_auth(
1672        router: axum::Router,
1673        method: &str,
1674        uri: &str,
1675        body: Option<Value>,
1676        auth: Option<&str>,
1677    ) -> (StatusCode, Value) {
1678        let mut req_builder = Request::builder()
1679            .method(method)
1680            .uri(uri)
1681            .header("content-type", "application/json");
1682        if let Some(a) = auth {
1683            req_builder = req_builder.header("authorization", a);
1684        }
1685        let req = if let Some(b) = body {
1686            let bytes = serde_json::to_vec(&b).unwrap();
1687            req_builder.body(Body::from(bytes)).unwrap()
1688        } else {
1689            req_builder = req_builder.header("content-length", "0");
1690            req_builder.body(Body::empty()).unwrap()
1691        };
1692        let resp = router.oneshot(req).await.expect("oneshot");
1693        let status = resp.status();
1694        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1695        let v: Value = if body_bytes.is_empty() {
1696            Value::Null
1697        } else {
1698            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1699        };
1700        (status, v)
1701    }
1702
1703    #[test]
1704    fn health_returns_ok() {
1705        let runtime = rt();
1706        let h = Harness::new(&runtime);
1707        let r = h.router.clone();
1708        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1709        assert_eq!(status, StatusCode::OK);
1710        h.shutdown(&runtime);
1711    }
1712
1713    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
1714    /// the four `memory.*` endpoints + their request/response schemas.
1715    /// Acts as a drift detector: if a future commit adds/removes a route
1716    /// without updating `openapi_spec`, this test fails loudly.
1717    #[test]
1718    fn openapi_json_describes_all_endpoints() {
1719        let runtime = rt();
1720        let h = Harness::new(&runtime);
1721        let r = h.router.clone();
1722        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1723        assert_eq!(status, StatusCode::OK);
1724        assert!(spec.is_object(), "openapi.json must be a JSON object");
1725
1726        // Top-level shape per OpenAPI 3.1.
1727        assert!(
1728            spec.get("openapi")
1729                .and_then(|v| v.as_str())
1730                .is_some_and(|s| s.starts_with("3.")),
1731            "missing or wrong openapi version: {spec}"
1732        );
1733        assert!(spec.pointer("/info/title").is_some());
1734        assert!(spec.pointer("/info/version").is_some());
1735
1736        // Every route the router serves must be documented.
1737        let paths = spec
1738            .get("paths")
1739            .and_then(|v| v.as_object())
1740            .expect("paths must be an object");
1741        for expected in [
1742            "/health",
1743            "/openapi.json",
1744            "/memory",
1745            "/memory/search",
1746            "/memory/consolidate",
1747            "/memory/{id}",
1748            // Path 1 derived-layer endpoints (v0.4.0+):
1749            "/memory/themes",
1750            "/memory/facts_about",
1751            "/memory/contradictions",
1752            // v0.5.0 Priority 3:
1753            "/memory/clusters/{cluster_id}",
1754            // v0.7.0 P6 — document operations:
1755            "/memory/documents",
1756            "/memory/documents/search",
1757            "/memory/documents/{id}",
1758        ] {
1759            assert!(
1760                paths.contains_key(expected),
1761                "openapi paths missing {expected}: {paths:?}"
1762            );
1763        }
1764
1765        // Method coverage on /memory/documents: must document both POST
1766        // (ingest) and GET (list).
1767        let docs = paths.get("/memory/documents").expect("/memory/documents");
1768        assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
1769        assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
1770
1771        // Method coverage on /memory/documents/{id}: must document both
1772        // GET (inspect) and DELETE (forget).
1773        let docid = paths
1774            .get("/memory/documents/{id}")
1775            .expect("/memory/documents/{id}");
1776        assert!(
1777            docid.get("get").is_some(),
1778            "GET /memory/documents/{{id}} undocumented"
1779        );
1780        assert!(
1781            docid.get("delete").is_some(),
1782            "DELETE /memory/documents/{{id}} undocumented"
1783        );
1784
1785        // Method coverage on /memory/{id}: must document both GET (inspect)
1786        // and DELETE (forget).
1787        let memid = paths.get("/memory/{id}").expect("memory/{id}");
1788        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1789        assert!(
1790            memid.get("delete").is_some(),
1791            "DELETE /memory/{{id}} undocumented"
1792        );
1793
1794        // Component schemas referenced from paths must be defined.
1795        for schema_name in [
1796            "RememberRequest",
1797            "RememberResponse",
1798            "RecallRequest",
1799            "RecallResult",
1800            "EpisodeRecord",
1801            "ApiError",
1802            "ConsolidationScope",
1803            "ConsolidationReport",
1804            // Path 1 derived-layer schemas (v0.4.0+):
1805            "ThemeHit",
1806            "FactHit",
1807            "ContradictionHit",
1808            // v0.5.0 Priority 3:
1809            "ClusterRecord",
1810            // v0.7.0 P6 — document schemas:
1811            "IngestDocumentRequest",
1812            "IngestReport",
1813            "ForgetDocumentReport",
1814            "SearchDocsRequest",
1815            "DocSearchHit",
1816            "DocumentInspectResult",
1817            "DocumentSummary",
1818        ] {
1819            let ptr = format!("/components/schemas/{schema_name}");
1820            assert!(
1821                spec.pointer(&ptr).is_some(),
1822                "component schema {schema_name} missing"
1823            );
1824        }
1825
1826        // bearerAuth security scheme is declared (LAN deployments need it).
1827        assert!(
1828            spec.pointer("/components/securitySchemes/bearerAuth")
1829                .is_some(),
1830            "bearerAuth security scheme missing"
1831        );
1832
1833        h.shutdown(&runtime);
1834    }
1835
1836    /// `/openapi.json` must remain unauthenticated even when bearer auth
1837    /// is enabled — the spec describes the API shape, not secrets, and
1838    /// codegen tooling shouldn't need a credential to fetch it.
1839    #[test]
1840    fn openapi_json_is_exempt_from_bearer_auth() {
1841        let runtime = rt();
1842        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1843        let r = h.router.clone();
1844        // No Authorization header → still 200 for /openapi.json.
1845        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1846        assert_eq!(status, StatusCode::OK);
1847        h.shutdown(&runtime);
1848    }
1849
1850    #[test]
1851    fn remember_returns_memory_id() {
1852        let runtime = rt();
1853        let h = Harness::new(&runtime);
1854        let r = h.router.clone();
1855        let (status, body) = runtime.block_on(call(
1856            r,
1857            "POST",
1858            "/memory",
1859            Some(json!({ "content": "http harness test" })),
1860        ));
1861        assert_eq!(status, StatusCode::OK);
1862        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1863        assert_eq!(mid.len(), 36, "uuid length");
1864        h.shutdown(&runtime);
1865    }
1866
1867    #[test]
1868    fn empty_content_returns_400() {
1869        let runtime = rt();
1870        let h = Harness::new(&runtime);
1871        let r = h.router.clone();
1872        let (status, body) =
1873            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1874        assert_eq!(status, StatusCode::BAD_REQUEST);
1875        assert!(
1876            body.get("error")
1877                .and_then(|e| e.as_str())
1878                .map(|s| s.contains("must not be empty"))
1879                .unwrap_or(false),
1880            "got: {body}"
1881        );
1882        h.shutdown(&runtime);
1883    }
1884
1885    #[test]
1886    fn empty_query_returns_400() {
1887        let runtime = rt();
1888        let h = Harness::new(&runtime);
1889        let r = h.router.clone();
1890        let (status, body) = runtime.block_on(call(
1891            r,
1892            "POST",
1893            "/memory/search",
1894            Some(json!({ "query": "" })),
1895        ));
1896        assert_eq!(status, StatusCode::BAD_REQUEST);
1897        assert!(
1898            body.get("error")
1899                .and_then(|e| e.as_str())
1900                .map(|s| s.contains("must not be empty"))
1901                .unwrap_or(false),
1902            "got: {body}"
1903        );
1904        h.shutdown(&runtime);
1905    }
1906
1907    #[test]
1908    fn inspect_unknown_returns_404() {
1909        let runtime = rt();
1910        let h = Harness::new(&runtime);
1911        let r = h.router.clone();
1912        let (status, body) = runtime.block_on(call(
1913            r,
1914            "GET",
1915            "/memory/00000000-0000-7000-8000-000000000000",
1916            None,
1917        ));
1918        assert_eq!(status, StatusCode::NOT_FOUND);
1919        assert!(body.get("error").is_some(), "got: {body}");
1920        h.shutdown(&runtime);
1921    }
1922
1923    #[test]
1924    fn inspect_invalid_id_returns_400() {
1925        let runtime = rt();
1926        let h = Harness::new(&runtime);
1927        let r = h.router.clone();
1928        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1929        assert_eq!(status, StatusCode::BAD_REQUEST);
1930        h.shutdown(&runtime);
1931    }
1932
1933    #[test]
1934    fn forget_unknown_returns_404() {
1935        let runtime = rt();
1936        let h = Harness::new(&runtime);
1937        let r = h.router.clone();
1938        let (status, _body) = runtime.block_on(call(
1939            r,
1940            "DELETE",
1941            "/memory/00000000-0000-7000-8000-000000000000",
1942            None,
1943        ));
1944        assert_eq!(status, StatusCode::NOT_FOUND);
1945        h.shutdown(&runtime);
1946    }
1947
1948    /// `POST /memory/consolidate` runs the cluster pass and returns
1949    /// the report as JSON. With an empty body, `ConsolidationScope`
1950    /// defaults to unbounded; with a non-empty body, the
1951    /// `window_days` field is honored. The Harness's writer is
1952    /// spawned without a Steward, so `abstractions_built` stays 0
1953    /// even when `clusters_built` is nonzero — same posture as the
1954    /// daemon today.
1955    #[test]
1956    fn consolidate_endpoint_returns_report() {
1957        let runtime = rt();
1958        let h = Harness::new(&runtime);
1959        let r = h.router.clone();
1960        runtime.block_on(async move {
1961            // Empty DB → all-zero report; structural assertion only.
1962            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1963            assert_eq!(status, StatusCode::OK);
1964            for field in [
1965                "episodes_seen",
1966                "clusters_built",
1967                "episodes_clustered",
1968                "abstractions_built",
1969                "triples_built",
1970                "contradictions_found",
1971            ] {
1972                assert!(
1973                    body.get(field).and_then(|v| v.as_u64()).is_some(),
1974                    "missing field {field}: {body}"
1975                );
1976            }
1977            assert_eq!(body["episodes_seen"], 0);
1978            assert_eq!(body["clusters_built"], 0);
1979
1980            // Non-empty body with window_days → still 200; unmistakable
1981            // shape round-trips through ConsolidationScope's serde.
1982            let (status2, _body2) = call(
1983                r,
1984                "POST",
1985                "/memory/consolidate",
1986                Some(json!({ "window_days": 7 })),
1987            )
1988            .await;
1989            assert_eq!(status2, StatusCode::OK);
1990        });
1991        h.shutdown(&runtime);
1992    }
1993
1994    #[test]
1995    fn auth_required_routes_reject_missing_token() {
1996        let runtime = rt();
1997        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1998        let r = h.router.clone();
1999        runtime.block_on(async move {
2000            // No Authorization header → 401.
2001            let (status, _body) = call(
2002                r.clone(),
2003                "POST",
2004                "/memory",
2005                Some(json!({ "content": "x" })),
2006            )
2007            .await;
2008            assert_eq!(status, StatusCode::UNAUTHORIZED);
2009
2010            // Wrong token → 401.
2011            let (status, _body) = call_with_auth(
2012                r.clone(),
2013                "POST",
2014                "/memory",
2015                Some(json!({ "content": "x" })),
2016                Some("Bearer wrong-token"),
2017            )
2018            .await;
2019            assert_eq!(status, StatusCode::UNAUTHORIZED);
2020
2021            // Correct token → handler runs (200).
2022            let (status, body) = call_with_auth(
2023                r.clone(),
2024                "POST",
2025                "/memory",
2026                Some(json!({ "content": "authed" })),
2027                Some("Bearer secret-xyz"),
2028            )
2029            .await;
2030            assert_eq!(status, StatusCode::OK);
2031            assert!(body.get("memory_id").is_some());
2032        });
2033        h.shutdown(&runtime);
2034    }
2035
2036    #[test]
2037    fn health_endpoint_does_not_require_auth() {
2038        let runtime = rt();
2039        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2040        let r = h.router.clone();
2041        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
2042        // Liveness probes should work without credentials.
2043        assert_eq!(status, StatusCode::OK);
2044        h.shutdown(&runtime);
2045    }
2046
2047    #[test]
2048    fn auth_response_includes_www_authenticate_header() {
2049        // Verify the WWW-Authenticate hint that lets a well-behaved
2050        // client know it's a bearer-auth scheme. We check via raw
2051        // request → response (oneshot returns Response, but our
2052        // call() helper drops the headers; build the request manually).
2053        let runtime = rt();
2054        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2055        let r = h.router.clone();
2056        runtime.block_on(async move {
2057            let req = Request::builder()
2058                .method("POST")
2059                .uri("/memory")
2060                .header("content-type", "application/json")
2061                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
2062                .unwrap();
2063            let resp = r.oneshot(req).await.unwrap();
2064            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2065            let www = resp
2066                .headers()
2067                .get("www-authenticate")
2068                .and_then(|v| v.to_str().ok())
2069                .unwrap_or("");
2070            assert!(
2071                www.starts_with("Bearer"),
2072                "expected WWW-Authenticate: Bearer..., got: {www}"
2073            );
2074        });
2075        h.shutdown(&runtime);
2076    }
2077
2078    // ---------------------------------------------------------------------
2079    // v0.8.0 P3: OIDC end-to-end. Spin up a fake IdP (wiremock) that
2080    // serves an OIDC discovery doc + JWKS, mint a token claiming
2081    // `solo_tenant = "default"`, and verify it routes through the
2082    // middleware + TenantExtractor + handler.
2083    // ---------------------------------------------------------------------
2084
2085    fn base64_url_for_test(bytes: &[u8]) -> String {
2086        use base64::Engine;
2087        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2088    }
2089
2090    /// Spin up a single-purpose fake OIDC IdP for these tests. Returns
2091    /// (mock_server, discovery_url, secret, kid).
2092    async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
2093        use wiremock::matchers::{method, path};
2094        use wiremock::{Mock, MockServer, ResponseTemplate};
2095        let server = MockServer::start().await;
2096        let secret = b"http-test-secret-for-hmac-fixture".to_vec();
2097        let kid = "http-test-kid";
2098        let discovery = serde_json::json!({
2099            "issuer": server.uri(),
2100            "jwks_uri": format!("{}/jwks", server.uri()),
2101        });
2102        Mock::given(method("GET"))
2103            .and(path("/.well-known/openid-configuration"))
2104            .respond_with(ResponseTemplate::new(200).set_body_json(discovery))
2105            .mount(&server)
2106            .await;
2107        let jwks = serde_json::json!({
2108            "keys": [
2109                {
2110                    "kty": "oct",
2111                    "kid": kid,
2112                    "alg": "HS256",
2113                    "k": base64_url_for_test(&secret),
2114                }
2115            ]
2116        });
2117        Mock::given(method("GET"))
2118            .and(path("/jwks"))
2119            .respond_with(ResponseTemplate::new(200).set_body_json(jwks))
2120            .mount(&server)
2121            .await;
2122        let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
2123        (server, discovery_url, secret, kid)
2124    }
2125
2126    fn mint_idp_token(
2127        server_uri: &str,
2128        kid: &str,
2129        secret: &[u8],
2130        tenant_claim: &str,
2131        audience: &str,
2132    ) -> String {
2133        use jsonwebtoken::{Algorithm, EncodingKey, Header};
2134        let mut header = Header::new(Algorithm::HS256);
2135        header.kid = Some(kid.to_string());
2136        let now = std::time::SystemTime::now()
2137            .duration_since(std::time::UNIX_EPOCH)
2138            .unwrap()
2139            .as_secs();
2140        let claims = serde_json::json!({
2141            "iss": server_uri,
2142            "sub": "test-user-1",
2143            "aud": audience,
2144            "exp": now + 600,
2145            "iat": now,
2146            "solo_tenant": tenant_claim,
2147        });
2148        jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
2149            .expect("mint token")
2150    }
2151
2152    #[test]
2153    fn http_oidc_accept_resolves_to_tenant_from_claim() {
2154        let runtime = rt();
2155        let (fake_server, discovery_url, secret, kid) =
2156            runtime.block_on(async { spin_fake_idp().await });
2157        let server_uri = fake_server.uri();
2158        // Keep the wiremock server alive for the duration of this test.
2159        let _server_guard = fake_server;
2160
2161        let auth = crate::auth::AuthConfig::Oidc {
2162            discovery_url,
2163            audience: "test-audience".to_string(),
2164            tenant_claim_name: "solo_tenant".to_string(),
2165        };
2166        let h = Harness::new_with_auth_config(&runtime, Some(auth));
2167        let r = h.router.clone();
2168
2169        // Mint a token claiming the harness's default tenant.
2170        let token = mint_idp_token(
2171            &server_uri,
2172            kid,
2173            &secret,
2174            "default",
2175            "test-audience",
2176        );
2177
2178        runtime.block_on(async move {
2179            // POST /memory with a valid OIDC token → handler runs, returns memory_id.
2180            let (status, body) = call_with_auth(
2181                r.clone(),
2182                "POST",
2183                "/memory",
2184                Some(json!({ "content": "oidc-routed content" })),
2185                Some(&format!("Bearer {token}")),
2186            )
2187            .await;
2188            assert_eq!(status, StatusCode::OK, "got body: {body}");
2189            assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
2190        });
2191        h.shutdown(&runtime);
2192    }
2193
2194    #[test]
2195    fn http_oidc_reject_missing_token_returns_401() {
2196        let runtime = rt();
2197        let (fake_server, discovery_url, _secret, _kid) =
2198            runtime.block_on(async { spin_fake_idp().await });
2199        let _server_guard = fake_server;
2200        let auth = crate::auth::AuthConfig::Oidc {
2201            discovery_url,
2202            audience: "test-audience".to_string(),
2203            tenant_claim_name: "solo_tenant".to_string(),
2204        };
2205        let h = Harness::new_with_auth_config(&runtime, Some(auth));
2206        let r = h.router.clone();
2207        runtime.block_on(async move {
2208            // No Authorization header.
2209            let (status, _body) =
2210                call(r.clone(), "POST", "/memory", Some(json!({ "content": "x" }))).await;
2211            assert_eq!(status, StatusCode::UNAUTHORIZED);
2212
2213            // Garbage token → 401 (invalid signature / not a JWT).
2214            let (status, _body) = call_with_auth(
2215                r.clone(),
2216                "POST",
2217                "/memory",
2218                Some(json!({ "content": "x" })),
2219                Some("Bearer not-a-real-jwt"),
2220            )
2221            .await;
2222            assert_eq!(status, StatusCode::UNAUTHORIZED);
2223        });
2224        h.shutdown(&runtime);
2225    }
2226
2227    #[test]
2228    fn full_remember_recall_inspect_forget_round_trip() {
2229        let runtime = rt();
2230        let h = Harness::new(&runtime);
2231        let r = h.router.clone();
2232        runtime.block_on(async move {
2233            // POST /memory
2234            let (status, body) = call(
2235                r.clone(),
2236                "POST",
2237                "/memory",
2238                Some(json!({ "content": "round-trip content" })),
2239            )
2240            .await;
2241            assert_eq!(status, StatusCode::OK);
2242            let mid = body
2243                .get("memory_id")
2244                .and_then(|v| v.as_str())
2245                .unwrap()
2246                .to_string();
2247
2248            // POST /memory/search — exact-match (StubEmbedder) returns the row.
2249            let (status, body) = call(
2250                r.clone(),
2251                "POST",
2252                "/memory/search",
2253                Some(json!({ "query": "round-trip content", "limit": 5 })),
2254            )
2255            .await;
2256            assert_eq!(status, StatusCode::OK);
2257            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2258            assert!(
2259                hits.iter()
2260                    .any(|h| h.get("content").and_then(|c| c.as_str())
2261                        == Some("round-trip content")),
2262                "expected hit with content; got: {body}"
2263            );
2264
2265            // GET /memory/{id}
2266            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2267            assert_eq!(status, StatusCode::OK);
2268            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
2269
2270            // DELETE /memory/{id}
2271            let (status, _body) =
2272                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
2273            assert_eq!(status, StatusCode::NO_CONTENT);
2274
2275            // GET again — still readable but status='forgotten'
2276            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2277            assert_eq!(status, StatusCode::OK);
2278            assert_eq!(
2279                body.get("status").and_then(|v| v.as_str()),
2280                Some("forgotten")
2281            );
2282
2283            // POST /memory/search — forgotten row excluded.
2284            let (status, body) = call(
2285                r.clone(),
2286                "POST",
2287                "/memory/search",
2288                Some(json!({ "query": "round-trip content", "limit": 5 })),
2289            )
2290            .await;
2291            assert_eq!(status, StatusCode::OK);
2292            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2293            assert!(
2294                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
2295                    != Some(mid.as_str())),
2296                "forgotten row should be excluded from recall: {body}"
2297            );
2298        });
2299        h.shutdown(&runtime);
2300    }
2301
2302    // Path 1 derived-layer endpoint tests (v0.4.0+). Wire-path only —
2303    // the actual content correctness is covered by solo-query::derived's
2304    // own tests (Sub-task A). These verify the HTTP shape: GET routing,
2305    // Query-string param parsing, JSON-array response body, validation
2306    // 400s for invalid inputs.
2307
2308    #[test]
2309    fn themes_endpoint_returns_empty_array_on_empty_db() {
2310        let runtime = rt();
2311        let h = Harness::new(&runtime);
2312        let r = h.router.clone();
2313        let (status, body) =
2314            runtime.block_on(call(r, "GET", "/memory/themes", None));
2315        assert_eq!(status, StatusCode::OK);
2316        assert!(body.is_array(), "expected array, got {body}");
2317        assert_eq!(body.as_array().unwrap().len(), 0);
2318        h.shutdown(&runtime);
2319    }
2320
2321    #[test]
2322    fn themes_endpoint_passes_through_query_params() {
2323        let runtime = rt();
2324        let h = Harness::new(&runtime);
2325        let r = h.router.clone();
2326        let (status, body) = runtime.block_on(call(
2327            r,
2328            "GET",
2329            "/memory/themes?window_days=7&limit=20",
2330            None,
2331        ));
2332        assert_eq!(status, StatusCode::OK);
2333        assert!(body.is_array(), "expected array, got {body}");
2334        h.shutdown(&runtime);
2335    }
2336
2337    #[test]
2338    fn facts_about_endpoint_requires_subject() {
2339        let runtime = rt();
2340        let h = Harness::new(&runtime);
2341        let r = h.router.clone();
2342        // Missing subject — axum's Query extractor 422 (Unprocessable
2343        // Entity) on missing required field; some axum versions
2344        // surface as 400. Accept either.
2345        let (status, _body) =
2346            runtime.block_on(call(r, "GET", "/memory/facts_about", None));
2347        assert!(
2348            status == StatusCode::BAD_REQUEST
2349                || status == StatusCode::UNPROCESSABLE_ENTITY,
2350            "expected 400 or 422 for missing subject, got {status}"
2351        );
2352        h.shutdown(&runtime);
2353    }
2354
2355    #[test]
2356    fn facts_about_endpoint_rejects_blank_subject() {
2357        let runtime = rt();
2358        let h = Harness::new(&runtime);
2359        let r = h.router.clone();
2360        // Whitespace-only subject reaches the handler then trips its
2361        // own validation → ApiError::bad_request → 400.
2362        let (status, body) = runtime.block_on(call(
2363            r,
2364            "GET",
2365            "/memory/facts_about?subject=%20%20",
2366            None,
2367        ));
2368        assert_eq!(status, StatusCode::BAD_REQUEST);
2369        assert!(
2370            body.get("error")
2371                .and_then(|v| v.as_str())
2372                .is_some_and(|s| s.contains("subject")),
2373            "expected error mentioning subject, got {body}"
2374        );
2375        h.shutdown(&runtime);
2376    }
2377
2378    #[test]
2379    fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
2380        let runtime = rt();
2381        let h = Harness::new(&runtime);
2382        let r = h.router.clone();
2383        let (status, body) = runtime.block_on(call(
2384            r,
2385            "GET",
2386            "/memory/facts_about?subject=NobodyKnows",
2387            None,
2388        ));
2389        assert_eq!(status, StatusCode::OK);
2390        assert_eq!(body.as_array().unwrap().len(), 0);
2391        h.shutdown(&runtime);
2392    }
2393
2394    #[test]
2395    fn facts_about_endpoint_parses_include_as_object_query_param() {
2396        // v0.5.1 P8: `?include_as_object=true` must parse cleanly
2397        // through the `Query<FactsAboutQuery>` extractor. If the
2398        // struct field is missing or wrongly typed, axum returns
2399        // 400/422 before reaching the handler. We don't seed
2400        // triples; we only need the request to reach the handler
2401        // and produce a normal 200 + empty array. Mirrors
2402        // `inspect_cluster_endpoint_passes_full_content_query_param`.
2403        let runtime = rt();
2404        let h = Harness::new(&runtime);
2405        let r = h.router.clone();
2406        let (status, body) = runtime.block_on(call(
2407            r,
2408            "GET",
2409            "/memory/facts_about?subject=Maya&include_as_object=true",
2410            None,
2411        ));
2412        assert_eq!(
2413            status,
2414            StatusCode::OK,
2415            "expected 200 with include_as_object query param, got {status}"
2416        );
2417        assert!(body.is_array());
2418        h.shutdown(&runtime);
2419    }
2420
2421    #[test]
2422    fn inspect_cluster_endpoint_unknown_id_returns_404() {
2423        // Maps `Error::NotFound` from `solo_query::inspect_cluster`
2424        // through `ApiError::from` → 404. Mirrors the unknown-memory
2425        // case for `GET /memory/{id}`.
2426        let runtime = rt();
2427        let h = Harness::new(&runtime);
2428        let r = h.router.clone();
2429        let (status, body) = runtime.block_on(call(
2430            r,
2431            "GET",
2432            "/memory/clusters/no-such-cluster",
2433            None,
2434        ));
2435        assert_eq!(status, StatusCode::NOT_FOUND);
2436        assert!(
2437            body.get("error")
2438                .and_then(|v| v.as_str())
2439                .is_some_and(|s| s.contains("no-such-cluster")),
2440            "expected error mentioning cluster id, got {body}"
2441        );
2442        h.shutdown(&runtime);
2443    }
2444
2445    #[test]
2446    fn inspect_cluster_endpoint_passes_full_content_query_param() {
2447        // Even with no matching cluster (→ 404), the request must
2448        // reach the handler — proves the `?full_content=true` query
2449        // string parses cleanly (Query<InspectClusterQuery>::default
2450        // path didn't choke). If we accidentally fail at the extractor
2451        // we'd get a 400/422, not the expected 404.
2452        let runtime = rt();
2453        let h = Harness::new(&runtime);
2454        let r = h.router.clone();
2455        let (status, _body) = runtime.block_on(call(
2456            r,
2457            "GET",
2458            "/memory/clusters/missing?full_content=true",
2459            None,
2460        ));
2461        assert_eq!(status, StatusCode::NOT_FOUND);
2462        h.shutdown(&runtime);
2463    }
2464
2465    #[test]
2466    fn contradictions_endpoint_returns_empty_array_on_empty_db() {
2467        let runtime = rt();
2468        let h = Harness::new(&runtime);
2469        let r = h.router.clone();
2470        let (status, body) = runtime.block_on(call(
2471            r,
2472            "GET",
2473            "/memory/contradictions",
2474            None,
2475        ));
2476        assert_eq!(status, StatusCode::OK);
2477        assert!(body.is_array());
2478        assert_eq!(body.as_array().unwrap().len(), 0);
2479        h.shutdown(&runtime);
2480    }
2481
2482    #[test]
2483    fn derived_endpoints_require_bearer_when_auth_enabled() {
2484        let runtime = rt();
2485        let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
2486        // Each of the three new endpoints should reject missing token.
2487        // Per the existing tests' shutdown-timing comment: don't hold a
2488        // long-lived router clone across multiple iterations — drop the
2489        // clone before each subsequent oneshot, and don't keep a `let r =
2490        // h.router.clone()` alive across h.shutdown(). Re-clone per
2491        // iteration; the per-call clone is consumed by oneshot.
2492        for path in [
2493            "/memory/themes",
2494            "/memory/facts_about?subject=Sam",
2495            "/memory/contradictions",
2496            "/memory/clusters/any-id",
2497        ] {
2498            let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
2499            assert_eq!(
2500                status,
2501                StatusCode::UNAUTHORIZED,
2502                "{path} should 401 without token"
2503            );
2504        }
2505        h.shutdown(&runtime);
2506    }
2507
2508    // ---- Document endpoints (v0.7.0 P6) ----
2509    //
2510    // Wire-path coverage. The `Harness` here uses
2511    // `WriterActor::spawn_full` without an embedder — same shape as the
2512    // existing handler tests. Ingest/search would fail at the writer
2513    // boundary with "writer has no embedder", but every other path
2514    // (404s, malformed ids, route shape, bearer auth gating, OpenAPI
2515    // documentation) is exercisable. Real end-to-end ingest→search
2516    // round-trip lives in `mcp_smoke.rs` where a real subprocess runs
2517    // with a fully-wired writer.
2518
2519    #[test]
2520    fn list_documents_endpoint_returns_empty_array_on_empty_db() {
2521        let runtime = rt();
2522        let h = Harness::new(&runtime);
2523        let r = h.router.clone();
2524        let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
2525        assert_eq!(status, StatusCode::OK);
2526        assert!(body.is_array(), "expected array, got {body}");
2527        assert_eq!(body.as_array().unwrap().len(), 0);
2528        h.shutdown(&runtime);
2529    }
2530
2531    #[test]
2532    fn list_documents_endpoint_parses_query_params() {
2533        let runtime = rt();
2534        let h = Harness::new(&runtime);
2535        let r = h.router.clone();
2536        let (status, body) = runtime.block_on(call(
2537            r,
2538            "GET",
2539            "/memory/documents?limit=5&offset=0&include_forgotten=true",
2540            None,
2541        ));
2542        assert_eq!(status, StatusCode::OK);
2543        assert!(body.is_array());
2544        h.shutdown(&runtime);
2545    }
2546
2547    #[test]
2548    fn ingest_document_endpoint_rejects_empty_path() {
2549        let runtime = rt();
2550        let h = Harness::new(&runtime);
2551        let r = h.router.clone();
2552        let (status, body) = runtime.block_on(call(
2553            r,
2554            "POST",
2555            "/memory/documents",
2556            Some(json!({ "path": "" })),
2557        ));
2558        assert_eq!(status, StatusCode::BAD_REQUEST);
2559        assert!(
2560            body.get("error")
2561                .and_then(|v| v.as_str())
2562                .is_some_and(|s| s.contains("path")),
2563            "expected error mentioning path, got {body}"
2564        );
2565        h.shutdown(&runtime);
2566    }
2567
2568    #[test]
2569    fn search_docs_endpoint_rejects_empty_query() {
2570        let runtime = rt();
2571        let h = Harness::new(&runtime);
2572        let r = h.router.clone();
2573        let (status, body) = runtime.block_on(call(
2574            r,
2575            "POST",
2576            "/memory/documents/search",
2577            Some(json!({ "query": "   " })),
2578        ));
2579        assert_eq!(status, StatusCode::BAD_REQUEST);
2580        assert!(
2581            body.get("error")
2582                .and_then(|v| v.as_str())
2583                .is_some_and(|s| s.contains("must not be empty")
2584                    || s.contains("doc_search")),
2585            "expected error mentioning empty query, got {body}"
2586        );
2587        h.shutdown(&runtime);
2588    }
2589
2590    #[test]
2591    fn inspect_document_endpoint_unknown_id_returns_404() {
2592        let runtime = rt();
2593        let h = Harness::new(&runtime);
2594        let r = h.router.clone();
2595        let (status, body) = runtime.block_on(call(
2596            r,
2597            "GET",
2598            "/memory/documents/00000000-0000-7000-8000-000000000000",
2599            None,
2600        ));
2601        assert_eq!(status, StatusCode::NOT_FOUND);
2602        assert!(body.get("error").is_some(), "got: {body}");
2603        h.shutdown(&runtime);
2604    }
2605
2606    #[test]
2607    fn inspect_document_endpoint_rejects_malformed_id() {
2608        let runtime = rt();
2609        let h = Harness::new(&runtime);
2610        let r = h.router.clone();
2611        let (status, _body) =
2612            runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
2613        assert_eq!(status, StatusCode::BAD_REQUEST);
2614        h.shutdown(&runtime);
2615    }
2616
2617    #[test]
2618    fn forget_document_endpoint_unknown_id_returns_404() {
2619        // Valid UUID format; no row exists → writer's `forget_document`
2620        // returns Error::NotFound → mapped to 404 by `ApiError::from`.
2621        let runtime = rt();
2622        let h = Harness::new(&runtime);
2623        let r = h.router.clone();
2624        let (status, _body) = runtime.block_on(call(
2625            r,
2626            "DELETE",
2627            "/memory/documents/00000000-0000-7000-8000-000000000000",
2628            None,
2629        ));
2630        assert_eq!(status, StatusCode::NOT_FOUND);
2631        h.shutdown(&runtime);
2632    }
2633
2634    #[test]
2635    fn forget_document_endpoint_rejects_malformed_id() {
2636        let runtime = rt();
2637        let h = Harness::new(&runtime);
2638        let r = h.router.clone();
2639        let (status, _body) =
2640            runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
2641        assert_eq!(status, StatusCode::BAD_REQUEST);
2642        h.shutdown(&runtime);
2643    }
2644
2645    #[test]
2646    fn document_endpoints_require_bearer_when_auth_enabled() {
2647        // All five doc endpoints sit behind the same authed Router and
2648        // must 401 without the bearer token. Mirrors
2649        // `derived_endpoints_require_bearer_when_auth_enabled`.
2650        let runtime = rt();
2651        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2652        let cases: &[(&str, &str, Option<Value>)] = &[
2653            ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
2654            ("GET", "/memory/documents", None),
2655            (
2656                "POST",
2657                "/memory/documents/search",
2658                Some(json!({ "query": "x" })),
2659            ),
2660            (
2661                "GET",
2662                "/memory/documents/00000000-0000-7000-8000-000000000000",
2663                None,
2664            ),
2665            (
2666                "DELETE",
2667                "/memory/documents/00000000-0000-7000-8000-000000000000",
2668                None,
2669            ),
2670        ];
2671        for (method, path, body) in cases {
2672            let (status, _) =
2673                runtime.block_on(call(h.router.clone(), method, path, body.clone()));
2674            assert_eq!(
2675                status,
2676                StatusCode::UNAUTHORIZED,
2677                "{method} {path} should 401 without token"
2678            );
2679        }
2680        h.shutdown(&runtime);
2681    }
2682
2683    #[test]
2684    fn document_endpoints_accept_correct_bearer_token() {
2685        // Sanity check: with the right token, the same five endpoints
2686        // pass auth and reach the handler. We only assert that the
2687        // status code is NOT 401 — exact downstream behaviour depends
2688        // on the harness (no embedder → ingest/search would 500; empty
2689        // DB → list/inspect/forget return 200/404).
2690        let runtime = rt();
2691        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2692        runtime.block_on(async {
2693            // GET /memory/documents → 200 + empty array (auth passes).
2694            let (status, _) = call_with_auth(
2695                h.router.clone(),
2696                "GET",
2697                "/memory/documents",
2698                None,
2699                Some("Bearer doc-secret"),
2700            )
2701            .await;
2702            assert_eq!(status, StatusCode::OK);
2703
2704            // GET /memory/documents/<unknown> → 404 (auth passes).
2705            let (status, _) = call_with_auth(
2706                h.router.clone(),
2707                "GET",
2708                "/memory/documents/00000000-0000-7000-8000-000000000000",
2709                None,
2710                Some("Bearer doc-secret"),
2711            )
2712            .await;
2713            assert_eq!(status, StatusCode::NOT_FOUND);
2714        });
2715        h.shutdown(&runtime);
2716    }
2717
2718    // ---------------------------------------------------------------------
2719    // v0.8.0 P2: tenant header extractor tests
2720    // ---------------------------------------------------------------------
2721
2722    /// `X-Solo-Tenant: default` resolves to the default tenant (which
2723    /// in the test harness is the only one wired in the registry).
2724    #[test]
2725    fn tenant_header_default_resolves() {
2726        let runtime = rt();
2727        let h = Harness::new(&runtime);
2728        let r = h.router.clone();
2729        let (status, _body) = runtime.block_on(async {
2730            let req = Request::builder()
2731                .method("GET")
2732                .uri("/memory/00000000-0000-7000-8000-000000000000")
2733                .header("x-solo-tenant", "default")
2734                .body(Body::empty())
2735                .unwrap();
2736            let resp = r.oneshot(req).await.expect("oneshot");
2737            let s = resp.status();
2738            let _b = resp.into_body().collect().await.unwrap().to_bytes();
2739            (s, _b)
2740        });
2741        // 404 because the id doesn't exist — but it's a routed 404 from
2742        // inspect_handler, not a 400 from a bad tenant header. That's
2743        // the proof point.
2744        assert_eq!(status, StatusCode::NOT_FOUND);
2745        h.shutdown(&runtime);
2746    }
2747
2748    /// `X-Solo-Tenant: UPPER` → 400 (invalid tenant id format).
2749    #[test]
2750    fn tenant_header_invalid_returns_400() {
2751        let runtime = rt();
2752        let h = Harness::new(&runtime);
2753        let r = h.router.clone();
2754        let (status, body) = runtime.block_on(async {
2755            let req = Request::builder()
2756                .method("GET")
2757                .uri("/memory/00000000-0000-7000-8000-000000000000")
2758                .header("x-solo-tenant", "UPPER")
2759                .body(Body::empty())
2760                .unwrap();
2761            let resp = r.oneshot(req).await.expect("oneshot");
2762            let s = resp.status();
2763            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2764            let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
2765            (s, v)
2766        });
2767        assert_eq!(status, StatusCode::BAD_REQUEST);
2768        let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
2769        assert!(
2770            msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
2771            "error must mention tenant/invalid: {msg}"
2772        );
2773        h.shutdown(&runtime);
2774    }
2775
2776    /// `X-Solo-Tenant: never-registered` → 404 (unknown tenant id).
2777    #[test]
2778    fn tenant_header_unknown_returns_404() {
2779        let runtime = rt();
2780        let h = Harness::new(&runtime);
2781        let r = h.router.clone();
2782        let (status, _body) = runtime.block_on(async {
2783            let req = Request::builder()
2784                .method("GET")
2785                .uri("/memory/00000000-0000-7000-8000-000000000000")
2786                .header("x-solo-tenant", "never-registered")
2787                .body(Body::empty())
2788                .unwrap();
2789            let resp = r.oneshot(req).await.expect("oneshot");
2790            let s = resp.status();
2791            let _b = resp.into_body().collect().await.unwrap().to_bytes();
2792            (s, _b)
2793        });
2794        assert_eq!(status, StatusCode::NOT_FOUND);
2795        h.shutdown(&runtime);
2796    }
2797
2798    /// No `X-Solo-Tenant` header → falls back to state.default_tenant.
2799    /// The reach-through to `inspect_handler` should produce the normal
2800    /// 404 for an unknown id rather than a tenant-routing error.
2801    #[test]
2802    fn tenant_header_missing_defaults_to_state_default_tenant() {
2803        let runtime = rt();
2804        let h = Harness::new(&runtime);
2805        let r = h.router.clone();
2806        let (status, _body) = runtime.block_on(async {
2807            let req = Request::builder()
2808                .method("GET")
2809                .uri("/memory/00000000-0000-7000-8000-000000000000")
2810                .body(Body::empty())
2811                .unwrap();
2812            let resp = r.oneshot(req).await.expect("oneshot");
2813            let s = resp.status();
2814            let _b = resp.into_body().collect().await.unwrap().to_bytes();
2815            (s, _b)
2816        });
2817        assert_eq!(status, StatusCode::NOT_FOUND);
2818        h.shutdown(&runtime);
2819    }
2820}
2821
2822#[cfg(test)]
2823mod cors_tests {
2824    use super::is_localhost_origin;
2825
2826    #[test]
2827    fn accepts_canonical_localhost_origins() {
2828        assert!(is_localhost_origin("http://localhost"));
2829        assert!(is_localhost_origin("http://localhost:3000"));
2830        assert!(is_localhost_origin("https://localhost:8443"));
2831        assert!(is_localhost_origin("http://127.0.0.1"));
2832        assert!(is_localhost_origin("http://127.0.0.1:5173"));
2833        assert!(is_localhost_origin("http://[::1]"));
2834        assert!(is_localhost_origin("http://[::1]:8080"));
2835    }
2836
2837    #[test]
2838    fn rejects_remote_origins() {
2839        assert!(!is_localhost_origin("http://example.com"));
2840        assert!(!is_localhost_origin("https://malicious.example"));
2841        assert!(!is_localhost_origin("http://192.168.1.5"));
2842        assert!(!is_localhost_origin("http://10.0.0.1"));
2843    }
2844
2845    #[test]
2846    fn rejects_dns_rebinding_tricks() {
2847        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
2848        // Origin header carries the public-DNS name. Rejecting these
2849        // closes the rebinding-via-Origin gap.
2850        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
2851        assert!(!is_localhost_origin("http://localhost.evil.com"));
2852        assert!(!is_localhost_origin("http://evil.localhost"));
2853    }
2854
2855    #[test]
2856    fn rejects_non_http_schemes() {
2857        assert!(!is_localhost_origin("file:///"));
2858        assert!(!is_localhost_origin("ws://localhost:3000"));
2859        assert!(!is_localhost_origin("javascript:alert(1)"));
2860    }
2861
2862    #[test]
2863    fn rejects_malformed() {
2864        assert!(!is_localhost_origin(""));
2865        assert!(!is_localhost_origin("localhost"));
2866        assert!(!is_localhost_origin("//localhost"));
2867    }
2868}
2869