Skip to main content

locus_gateway/
gateway.rs

1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use anyhow::{Result, anyhow};
5use axum::extract::{Query, State};
6use axum::http::{HeaderMap, Method, StatusCode};
7use axum::routing::{get, post};
8use axum::{Json, Router};
9use chrono::{DateTime, Utc};
10use clap::Parser;
11use serde_json::{Value, json};
12use tokio::net::TcpListener;
13use tonic::transport::Server;
14use tonic::{Request, Response, Status};
15use tower_http::cors::{Any, CorsLayer};
16use tracing::{error, info};
17use tracing_subscriber::EnvFilter;
18
19use locus_core_rs::domain::models::{
20    self as core_models, ConfidenceBandSummary, MonthlyRollupRequest, NumericRange, PsiRange,
21};
22use locus_core_rs::domain::contracts::EmbeddingProvider;
23use locus_sdk::application::memory_evict::MemoryEvictService;
24use locus_sdk::application::memory_find::MemoryFindService;
25use locus_sdk::application::memory_graph::{MemoryGraphService, graph_node_id};
26use locus_sdk::application::memory_recall::MemoryRecallService;
27use locus_sdk::application::memory_transform::MemoryTransformService;
28use locus_sdk::domain::evict::{MemoryEvictMode, MemoryEvictRequest, MemoryEvictResult};
29use locus_sdk::domain::graph::MemoryGraphRequest;
30use locus_sdk::domain::memory::{
31    FallbackPolicy, MemoryFilter, MemoryFindRequest, MemoryPage, MemoryRecallRequest,
32    MemoryScope, MemoryScoring, MemoryTransformOperation, MemoryTransformRequest,
33};
34use locus_sdk::infrastructure::registry::InMemoryAiProviderRegistry;
35use locus_sdk::infrastructure::sttp_native::embedding_provider_adapter::SttpEmbeddingProviderAdapter;
36
37use crate::app_state::AppState;
38use crate::constants::{
39    DEFAULT_HYBRID_ALPHA, DEFAULT_HYBRID_BETA, FILE_DESCRIPTOR_SET, TENANT_SCAN_LIMIT,
40};
41use crate::gateway_args::GatewayArgs;
42use crate::http_models::*;
43use crate::orchestration::{build_state, parse_cors_allowed_origins, shutdown_signal};
44#[cfg(test)]
45use crate::orchestration::build_in_memory_state;
46use crate::providers::resolve_query_embedding;
47use crate::tenant::{
48    display_session_id, normalize_node_for_tenant, normalize_tenant_value, resolve_grpc_tenant,
49    resolve_http_tenant, scope_session_id,
50};
51
52pub mod proto {
53    tonic::include_proto!("sttp.v1");
54}
55
56pub(crate) async fn run() -> Result<()> {
57    tracing_subscriber::fmt()
58        .with_env_filter(EnvFilter::from_default_env())
59        .init();
60
61    let args = GatewayArgs::parse();
62
63    if args.http_port == args.grpc_port {
64        return Err(anyhow!(
65            "--http-port and --grpc-port must be different values"
66        ));
67    }
68
69    let state = Arc::new(build_state(&args).await?);
70
71    let base_router = Router::new()
72        .route("/health", get(health_handler))
73        .route("/api/v1/calibrate", post(calibrate_handler))
74        .route("/api/v1/store", post(store_context_handler))
75        .route("/api/store", post(store_context_handler))
76        .route("/store", post(store_context_handler))
77        .route("/api/v1/avec/score", post(score_avec_handler))
78        .route("/api/avec/score", post(score_avec_handler))
79        .route("/avec/score", post(score_avec_handler))
80        .route("/api/v1/session/rename", post(rename_session_handler))
81        .route("/api/session/rename", post(rename_session_handler))
82        .route("/session/rename", post(rename_session_handler))
83        .route("/api/v1/context", post(get_context_handler))
84        .route(
85            "/api/v1/context/embeddings",
86            post(get_embedding_context_handler),
87        )
88        .route(
89            "/api/context/embeddings",
90            post(get_embedding_context_handler),
91        )
92        .route("/context/embeddings", post(get_embedding_context_handler))
93        .route("/api/v1/nodes", get(list_nodes_handler))
94        .route("/api/nodes", get(list_nodes_handler))
95        .route("/nodes", get(list_nodes_handler))
96        .route("/api/v1/graph", get(graph_handler))
97        .route("/api/graph", get(graph_handler))
98        .route("/graph", get(graph_handler))
99        .route("/api/v1/evict", post(evict_handler))
100        .route("/api/evict", post(evict_handler))
101        .route("/evict", post(evict_handler))
102        .route("/api/v1/moods", get(get_moods_handler))
103        .route("/api/v1/rekey", post(batch_rekey_handler))
104        .route(
105            "/api/v1/rollups/monthly",
106            post(create_monthly_rollup_handler),
107        )
108        .route(
109            "/api/v1/embeddings/migration/preview",
110            post(preview_embedding_migration_handler),
111        )
112        .route(
113            "/api/v1/embeddings/migration/run",
114            post(run_embedding_migration_handler),
115        )
116        .with_state(state.clone());
117
118    let http_router = if args.cors_enabled {
119        let allowed_origins = parse_cors_allowed_origins(&args.cors_allowed_origins)?;
120        let cors_base = CorsLayer::new()
121            .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::OPTIONS])
122            .allow_headers(Any);
123
124        let cors = match allowed_origins {
125            CorsAllowedOrigins::Any => cors_base.allow_origin(Any),
126            CorsAllowedOrigins::Explicit(origins) => cors_base.allow_origin(origins),
127        };
128
129        base_router.layer(cors)
130    } else {
131        base_router
132    };
133
134    let grpc_service = GrpcGatewayService::new(state);
135
136    let grpc_addr = SocketAddr::from(([0, 0, 0, 0], args.grpc_port));
137    let http_listener = TcpListener::bind(("0.0.0.0", args.http_port)).await?;
138
139    let reflection_service = tonic_reflection::server::Builder::configure()
140        .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
141        .build_v1()?;
142
143    info!(
144        http_port = args.http_port,
145        grpc_port = args.grpc_port,
146        cors_enabled = args.cors_enabled,
147        cors_allowed_origins = %args.cors_allowed_origins,
148        "Starting locus-gateway"
149    );
150
151    let http_server =
152        axum::serve(http_listener, http_router).with_graceful_shutdown(shutdown_signal());
153    let grpc_server = Server::builder()
154        .add_service(reflection_service)
155        .add_service(
156            proto::sttp_gateway_service_server::SttpGatewayServiceServer::new(grpc_service),
157        )
158        .serve_with_shutdown(grpc_addr, shutdown_signal());
159
160    let (http_result, grpc_result) = tokio::join!(http_server, grpc_server);
161    if let Err(err) = http_result {
162        error!(error = %err, "HTTP server exited with error");
163        return Err(err.into());
164    }
165    if let Err(err) = grpc_result {
166        error!(error = %err, "gRPC server exited with error");
167        return Err(err.into());
168    }
169
170    Ok(())
171}
172
173async fn health_handler() -> Json<Value> {
174    Json(json!({ "status": "ok", "transport": "http+grpc" }))
175}
176
177async fn calibrate_handler(
178    State(state): State<Arc<AppState>>,
179    headers: HeaderMap,
180    Json(request): Json<CalibrateSessionHttpRequest>,
181) -> ApiResult<CalibrationResultDto> {
182    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
183    let scoped_session_id = scope_session_id(&tenant, &request.session_id);
184
185    let trigger = request
186        .trigger
187        .as_deref()
188        .filter(|v| !v.trim().is_empty())
189        .unwrap_or("manual");
190
191    let result = state
192        .calibration
193        .calibrate_async(
194            &scoped_session_id,
195            request.stability,
196            request.friction,
197            request.logic,
198            request.autonomy,
199            trigger,
200        )
201        .await
202        .map_err(internal_error)?;
203
204    Ok(Json(CalibrationResultDto {
205        previous_avec: to_avec_dto(result.previous_avec),
206        delta: result.delta,
207        drift_classification: format!("{:?}", result.drift_classification),
208        trigger: result.trigger,
209        trigger_history: result.trigger_history,
210        is_first_calibration: result.is_first_calibration,
211    }))
212}
213
214async fn store_context_handler(
215    State(state): State<Arc<AppState>>,
216    headers: HeaderMap,
217    Json(request): Json<StoreContextHttpRequest>,
218) -> ApiResult<StoreResultDto> {
219    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
220    let scoped_session_id = scope_session_id(&tenant, &request.session_id);
221
222    let result = state
223        .store_context
224        .store_async(&request.node, &scoped_session_id)
225        .await;
226
227    Ok(Json(StoreResultDto {
228        node_id: result.node_id,
229        psi: result.psi,
230        valid: result.valid,
231        validation_error: result.validation_error,
232        duplicate_skipped: false,
233        upsert_status: if result.valid {
234            "created".to_string()
235        } else {
236            "skipped".to_string()
237        },
238    }))
239}
240
241async fn score_avec_handler(
242    State(state): State<Arc<AppState>>,
243    headers: HeaderMap,
244    Json(request): Json<ScoreAvecHttpRequest>,
245) -> ApiResult<ScoreAvecResultDto> {
246    let _tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
247
248    if request.text.trim().is_empty() {
249        return Err(bad_request("text cannot be empty"));
250    }
251
252    let scorer = state.avec_scorer.as_ref().ok_or_else(|| {
253        bad_request("AVEC scoring is disabled; enable LOCUS_GATEWAY_AVEC_SCORING_ENABLED")
254    })?;
255
256    let avec = scorer
257        .score_async(request.text.trim())
258        .await
259        .map_err(internal_error)?;
260
261    Ok(Json(ScoreAvecResultDto {
262        provider: scorer.provider_name().to_string(),
263        model: scorer.model_name().to_string(),
264        avec: to_avec_dto(avec),
265    }))
266}
267
268async fn rename_session_handler(
269    State(state): State<Arc<AppState>>,
270    headers: HeaderMap,
271    Json(request): Json<RenameSessionHttpRequest>,
272) -> ApiResult<RenameSessionResultDto> {
273    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
274    let source_session_id = request.source_session_id.trim();
275    let target_session_id = request.target_session_id.trim();
276
277    if source_session_id.is_empty() || target_session_id.is_empty() {
278        return Err(bad_request(
279            "sourceSessionId and targetSessionId are required",
280        ));
281    }
282
283    if source_session_id == target_session_id {
284        return Ok(Json(RenameSessionResultDto {
285            source_session_id: source_session_id.to_string(),
286            target_session_id: target_session_id.to_string(),
287            moved_nodes: 0,
288            moved_calibrations: 0,
289            scopes_applied: 0,
290        }));
291    }
292
293    let scoped_source_session_id = scope_session_id(&tenant, source_session_id);
294    let scoped_target_session_id = scope_session_id(&tenant, target_session_id);
295
296    let source_nodes = state
297        .node_store
298        .query_nodes_async(core_models::NodeQuery {
299            limit: 10_000,
300            session_id: Some(scoped_source_session_id.clone()),
301            from_utc: None,
302            to_utc: None,
303            tiers: None,
304        })
305        .await
306        .map_err(internal_error)?;
307
308    if source_nodes.is_empty() {
309        return Err(bad_request(format!(
310            "source session not found: {source_session_id}"
311        )));
312    }
313
314    let mut anchor_node_ids = Vec::with_capacity(source_nodes.len());
315    for node in source_nodes {
316        let upsert = state
317            .node_store
318            .upsert_node_async(node)
319            .await
320            .map_err(internal_error)?;
321        anchor_node_ids.push(upsert.node_id);
322    }
323    anchor_node_ids.sort();
324    anchor_node_ids.dedup();
325
326    let rekey_result = state
327        .rekey_scope
328        .rekey_async(
329            anchor_node_ids,
330            &tenant,
331            &scoped_target_session_id,
332            false,
333            request.allow_merge.unwrap_or(false),
334        )
335        .await
336        .map_err(internal_error)?;
337
338    if let Some(conflict) = rekey_result.scopes.iter().find(|scope| scope.conflict) {
339        return Err(bad_request(
340            conflict
341                .message
342                .clone()
343                .unwrap_or_else(|| "target session already exists".to_string()),
344        ));
345    }
346
347    let scopes_applied = rekey_result
348        .scopes
349        .iter()
350        .filter(|scope| scope.applied)
351        .count();
352
353    Ok(Json(RenameSessionResultDto {
354        source_session_id: source_session_id.to_string(),
355        target_session_id: target_session_id.to_string(),
356        moved_nodes: rekey_result.temporal_nodes_updated,
357        moved_calibrations: rekey_result.calibrations_updated,
358        scopes_applied,
359    }))
360}
361
362async fn get_context_handler(
363    State(state): State<Arc<AppState>>,
364    headers: HeaderMap,
365    Json(request): Json<GetContextHttpRequest>,
366) -> ApiResult<RetrieveResultDto> {
367    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
368    let scoped_session_id = scope_session_id(&tenant, &request.session_id);
369
370    let limit = request.limit.unwrap_or(5);
371    let tiers = normalize_request_tiers(request.tiers.as_deref());
372    let query_embedding = resolve_query_embedding(
373        state.embedding_provider.as_ref(),
374        request.query_text.as_deref(),
375        request.query_embedding.as_deref(),
376    )
377    .await;
378    let recall_service = recall_service(&state);
379    let memory_filter = build_semantic_memory_filter(
380        request.semantic_tags,
381        request.tags_contains,
382        request.link_rel,
383        request.link_target,
384        request.links_to_ref,
385        request.tag_prefix,
386        request.has_semantic_links,
387    );
388    let recall_result = recall_service
389        .execute(&MemoryRecallRequest {
390            scope: MemoryScope {
391                tenant_id: None,
392                session_ids: Some(vec![scoped_session_id]),
393                tiers,
394                from_utc: request.from_utc,
395                to_utc: request.to_utc,
396            },
397            page: MemoryPage {
398                limit,
399                cursor: None,
400            },
401            scoring: MemoryScoring {
402                alpha: request
403                    .alpha
404                    .unwrap_or(DEFAULT_HYBRID_ALPHA)
405                    .clamp(0.0, 1.0),
406                beta: request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
407                gamma: request.gamma.unwrap_or(0.0).clamp(0.0, 1.0),
408                fallback_policy: FallbackPolicy::Never,
409                ..Default::default()
410            },
411            filter: memory_filter,
412            current_avec: Some(core_models::AvecState {
413                stability: request.stability,
414                friction: request.friction,
415                logic: request.logic,
416                autonomy: request.autonomy,
417            }),
418            query_text: request.query_text,
419            query_embedding,
420            ..Default::default()
421        })
422        .await
423        .map_err(internal_error)?;
424
425    Ok(Json(RetrieveResultDto {
426        nodes: recall_result.nodes.iter().map(to_node_dto).collect(),
427        retrieved: recall_result.retrieved,
428        psi_range: PsiRangeDto {
429            min: recall_result.psi_range.min,
430            max: recall_result.psi_range.max,
431            average: recall_result.psi_range.average,
432        },
433    }))
434}
435
436async fn get_embedding_context_handler(
437    State(state): State<Arc<AppState>>,
438    headers: HeaderMap,
439    Json(request): Json<GetEmbeddingContextHttpRequest>,
440) -> ApiResult<RetrieveResultDto> {
441    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
442    let scoped_session_id = scope_session_id(&tenant, &request.session_id);
443    let limit = request.limit.unwrap_or(5);
444    let tiers = normalize_request_tiers(request.tiers.as_deref());
445
446    let rag_embedding = resolve_query_embedding(
447        state.embedding_provider.as_ref(),
448        request.rag_query_text.as_deref(),
449        request.rag_embedding.as_deref(),
450    )
451    .await;
452
453    let avec_embedding = resolve_query_embedding(
454        state.embedding_provider.as_ref(),
455        request.avec_query_text.as_deref(),
456        request.avec_embedding.as_deref(),
457    )
458    .await;
459
460    let fused_embedding = fuse_weighted_embeddings(
461        rag_embedding.as_deref(),
462        avec_embedding.as_deref(),
463        request.rag_weight.unwrap_or(0.7),
464        request.avec_weight.unwrap_or(0.3),
465    )?;
466
467    if fused_embedding.is_empty() {
468        return Err(bad_request(
469            "Provide ragEmbedding/ragQueryText and/or avecEmbedding/avecQueryText",
470        ));
471    }
472
473    let result = state
474        .context_query
475        .get_context_hybrid_scoped_filtered_async(
476            Some(&scoped_session_id),
477            request.stability,
478            request.friction,
479            request.logic,
480            request.autonomy,
481            request.from_utc,
482            request.to_utc,
483            tiers.as_deref(),
484            Some(fused_embedding.as_slice()),
485            request
486                .alpha
487                .unwrap_or(DEFAULT_HYBRID_ALPHA)
488                .clamp(0.0, 1.0),
489            request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
490            limit,
491        )
492        .await;
493
494    Ok(Json(RetrieveResultDto {
495        nodes: result.nodes.iter().map(to_node_dto).collect(),
496        retrieved: result.retrieved,
497        psi_range: PsiRangeDto {
498            min: result.psi_range.min,
499            max: result.psi_range.max,
500            average: result.psi_range.average,
501        },
502    }))
503}
504
505fn fuse_weighted_embeddings(
506    rag_embedding: Option<&[f32]>,
507    avec_embedding: Option<&[f32]>,
508    rag_weight: f32,
509    avec_weight: f32,
510) -> Result<Vec<f32>, (StatusCode, Json<ErrorResponse>)> {
511    let rag_weight = rag_weight.clamp(0.0, 1.0);
512    let avec_weight = avec_weight.clamp(0.0, 1.0);
513
514    match (rag_embedding, avec_embedding) {
515        (Some(rag), Some(avec)) => {
516            if rag.len() != avec.len() {
517                return Err(bad_request(
518                    "rag embedding and avec embedding must have the same dimensions",
519                ));
520            }
521
522            let sum = rag_weight + avec_weight;
523            let denom = if sum > f32::EPSILON { sum } else { 1.0 };
524            let fused = rag
525                .iter()
526                .zip(avec.iter())
527                .map(|(r, a)| ((r * rag_weight) + (a * avec_weight)) / denom)
528                .collect::<Vec<_>>();
529            Ok(fused)
530        }
531        (Some(rag), None) => Ok(rag.to_vec()),
532        (None, Some(avec)) => Ok(avec.to_vec()),
533        (None, None) => Ok(Vec::new()),
534    }
535}
536
537async fn list_nodes_handler(
538    State(state): State<Arc<AppState>>,
539    headers: HeaderMap,
540    Query(query): Query<ListNodesQuery>,
541) -> ApiResult<ListNodesResultDto> {
542    let tenant = resolve_http_tenant(query.tenant_id.as_deref(), &headers);
543    let requested_limit = query.limit.unwrap_or(50).clamp(1, TENANT_SCAN_LIMIT);
544    let scoped_session_filter = query
545        .session_id
546        .as_deref()
547        .map(|session_id| scope_session_id(&tenant, session_id));
548    let backend_limit = if scoped_session_filter.is_some() {
549        requested_limit
550    } else {
551        TENANT_SCAN_LIMIT
552    };
553
554    let find_service = find_service(&state);
555    let result = find_service
556        .execute(&MemoryFindRequest {
557            scope: MemoryScope {
558                tenant_id: None,
559                session_ids: scoped_session_filter.map(|session| vec![session]),
560                tiers: None,
561                from_utc: None,
562                to_utc: None,
563            },
564            filter: build_semantic_memory_filter(
565                query.semantic_tags,
566                query.tags_contains,
567                query.link_rel,
568                query.link_target,
569                query.links_to_ref,
570                query.tag_prefix,
571                query.has_semantic_links,
572            ),
573            page: MemoryPage {
574                limit: backend_limit,
575                cursor: None,
576            },
577            ..Default::default()
578        })
579        .await
580        .map_err(internal_error)?;
581
582    let nodes = result
583        .nodes
584        .into_iter()
585        .filter_map(|node| normalize_node_for_tenant(node, &tenant))
586        .take(requested_limit)
587        .collect::<Vec<_>>();
588
589    Ok(Json(ListNodesResultDto {
590        nodes: nodes.iter().map(to_node_dto).collect(),
591        retrieved: nodes.len(),
592    }))
593}
594
595async fn graph_handler(
596    State(state): State<Arc<AppState>>,
597    headers: HeaderMap,
598    Query(query): Query<GraphQuery>,
599) -> ApiResult<GraphResponse> {
600    let tenant = resolve_http_tenant(query.tenant_id.as_deref(), &headers);
601    let scoped_session_filter = query
602        .session_id
603        .as_deref()
604        .map(|session_id| scope_session_id(&tenant, session_id));
605
606    let graph_result = graph_service(&state)
607        .execute(&MemoryGraphRequest {
608            scope: MemoryScope {
609                tenant_id: Some(tenant.clone()),
610                session_ids: scoped_session_filter.map(|session| vec![session]),
611                tiers: None,
612                from_utc: None,
613                to_utc: None,
614            },
615            filter: build_semantic_memory_filter(
616                query.semantic_tags,
617                None,
618                query.link_rel,
619                query.link_target,
620                query.links_to_ref,
621                query.tag_prefix,
622                query.has_semantic_links,
623            ),
624            include_lineage: true,
625            include_semantic: true,
626            include_session_topology: true,
627            rel: query.rel,
628            target_prefix: query.target_prefix,
629            limit: query.limit.unwrap_or(1000),
630        })
631        .await
632        .map_err(internal_error)?;
633
634    Ok(Json(GraphResponse {
635        sessions: graph_result.sessions,
636        nodes: graph_result.nodes,
637        edges: graph_result.edges,
638        retrieved: graph_result.retrieved,
639    }))
640}
641
642async fn evict_handler(
643    State(state): State<Arc<AppState>>,
644    headers: HeaderMap,
645    Json(request): Json<EvictHttpRequest>,
646) -> ApiResult<EvictHttpResultDto> {
647    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
648    let scoped_session = scope_session_id(&tenant, &request.session_id);
649    let mode = resolve_evict_mode(&request)?;
650
651    let purge_session = matches!(mode, MemoryEvictMode::PurgeSession);
652    let include_calibration = request
653        .include_calibration
654        .unwrap_or(purge_session);
655    let include_checkpoints = request
656        .include_checkpoints
657        .unwrap_or(purge_session);
658
659    let result = evict_service(&state)
660        .execute(&MemoryEvictRequest {
661            mode,
662            scope: MemoryScope {
663                tenant_id: Some(tenant),
664                session_ids: Some(vec![scoped_session]),
665                tiers: normalize_request_tiers(request.tiers.as_deref()),
666                from_utc: None,
667                to_utc: None,
668            },
669            filter: build_semantic_memory_filter(
670                request.semantic_tags,
671                request.tags_contains,
672                request.link_rel,
673                request.link_target,
674                request.links_to_ref,
675                request.tag_prefix,
676                request.has_semantic_links,
677            ),
678            sync_keys: request.sync_keys,
679            node_ids: request.node_ids,
680            dry_run: request.dry_run.unwrap_or(false),
681            force: request.force.unwrap_or(false),
682            max_nodes: request.max_nodes.unwrap_or(5000),
683            include_calibration,
684            include_checkpoints,
685        })
686        .await
687        .map_err(internal_error)?;
688
689    Ok(Json(to_evict_dto(result)))
690}
691
692fn resolve_evict_mode(request: &EvictHttpRequest) -> Result<MemoryEvictMode, (StatusCode, Json<ErrorResponse>)> {
693    if request.purge_session.unwrap_or(false) {
694        return Ok(MemoryEvictMode::PurgeSession);
695    }
696
697    if let Some(mode) = request.mode.as_deref() {
698        return match mode.trim().to_ascii_lowercase().as_str() {
699            "by_sync_keys" | "sync_keys" => Ok(MemoryEvictMode::BySyncKeys),
700            "by_node_ids" | "node_ids" => Ok(MemoryEvictMode::ByNodeIds),
701            "by_filter" | "filter" => Ok(MemoryEvictMode::ByFilter),
702            "purge_session" | "purge" => Ok(MemoryEvictMode::PurgeSession),
703            _ => Err(bad_request(format!("unsupported evict mode: {mode}"))),
704        };
705    }
706
707    if request.sync_keys.as_ref().is_some_and(|keys| !keys.is_empty()) {
708        return Ok(MemoryEvictMode::BySyncKeys);
709    }
710    if request.node_ids.as_ref().is_some_and(|ids| !ids.is_empty()) {
711        return Ok(MemoryEvictMode::ByNodeIds);
712    }
713    if request.semantic_tags.is_some()
714        || request.tags_contains.is_some()
715        || request.link_rel.is_some()
716        || request.link_target.is_some()
717        || request.links_to_ref.is_some()
718        || request.tag_prefix.is_some()
719        || request.has_semantic_links.is_some()
720    {
721        return Ok(MemoryEvictMode::ByFilter);
722    }
723
724    Err(bad_request(
725        "evict mode could not be inferred; provide syncKeys, nodeIds, filter fields, or purgeSession=true",
726    ))
727}
728
729fn to_evict_dto(result: MemoryEvictResult) -> EvictHttpResultDto {
730    EvictHttpResultDto {
731        dry_run: result.dry_run,
732        deleted: result.deleted,
733        blocked: result.blocked,
734        not_found: result.not_found,
735        skipped: result.skipped,
736        would_delete: result.would_delete,
737        calibrations_deleted: result.calibrations_deleted,
738        checkpoints_deleted: result.checkpoints_deleted,
739        records: result
740            .records
741            .into_iter()
742            .map(|record| {
743                json!({
744                    "nodeId": record.node_id,
745                    "syncKey": record.sync_key,
746                    "status": record.status,
747                    "reason": record.reason,
748                    "inboundReferences": record.inbound_references,
749                })
750            })
751            .collect(),
752    }
753}
754
755async fn get_moods_handler(
756    State(state): State<Arc<AppState>>,
757    Query(query): Query<GetMoodsQuery>,
758) -> ApiResult<MoodCatalogResultDto> {
759    let result = state.mood_catalog.get(
760        query.target_mood.as_deref(),
761        query.blend.unwrap_or(1.0),
762        query.current_stability,
763        query.current_friction,
764        query.current_logic,
765        query.current_autonomy,
766    );
767
768    Ok(Json(to_mood_catalog_dto(result)))
769}
770
771async fn create_monthly_rollup_handler(
772    State(state): State<Arc<AppState>>,
773    headers: HeaderMap,
774    Json(request): Json<CreateMonthlyRollupHttpRequest>,
775) -> ApiResult<MonthlyRollupResultDto> {
776    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
777
778    let rollup_request = MonthlyRollupRequest {
779        session_id: scope_session_id(&tenant, &request.session_id),
780        start_utc: request.start_date_utc,
781        end_utc: request.end_date_utc,
782        source_session_id: request
783            .source_session_id
784            .map(|session_id| scope_session_id(&tenant, &session_id)),
785        parent_node_id: request.parent_node_id,
786        persist: request.persist.unwrap_or(true),
787        limit: request.limit.unwrap_or(5000),
788    };
789
790    let result = state.monthly_rollup.create_async(rollup_request).await;
791    Ok(Json(to_monthly_rollup_dto(result)))
792}
793
794async fn batch_rekey_handler(
795    State(state): State<Arc<AppState>>,
796    headers: HeaderMap,
797    Json(request): Json<BatchRekeyHttpRequest>,
798) -> ApiResult<BatchRekeyResultDto> {
799    if request.node_ids.is_empty() {
800        return Err(bad_request("nodeIds must contain at least one value"));
801    }
802
803    if request.target_session_id.trim().is_empty() {
804        return Err(bad_request("targetSessionId cannot be empty"));
805    }
806
807    let target_tenant = resolve_http_tenant(request.target_tenant_id.as_deref(), &headers);
808    let scoped_target_session = scope_session_id(&target_tenant, request.target_session_id.trim());
809
810    let result = state
811        .rekey_scope
812        .rekey_async(
813            request.node_ids,
814            &target_tenant,
815            &scoped_target_session,
816            request.dry_run.unwrap_or(true),
817            request.allow_merge.unwrap_or(false),
818        )
819        .await
820        .map_err(internal_error)?;
821
822    Ok(Json(to_batch_rekey_dto(result)))
823}
824
825async fn preview_embedding_migration_handler(
826    State(state): State<Arc<AppState>>,
827    headers: HeaderMap,
828    Json(request): Json<EmbeddingMigrationPreviewHttpRequest>,
829) -> ApiResult<EmbeddingMigrationPreviewResultDto> {
830    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
831    let sample_limit = request.sample_limit.unwrap_or(20).clamp(1, 200);
832    let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
833    let (scope, filter, sync_keys) = scoped_memory_filter(request.filter, &tenant);
834
835    let find_service = find_service(&state);
836    let find_result = find_service
837        .execute(&MemoryFindRequest {
838            scope,
839            filter,
840            page: MemoryPage {
841                limit: max_nodes,
842                cursor: None,
843            },
844            ..Default::default()
845        })
846        .await
847        .map_err(internal_error)?;
848
849    let mut nodes = find_result.nodes;
850    if let Some(sync_keys) = sync_keys {
851        nodes.retain(|node| sync_keys.iter().any(|key| key == &node.sync_key));
852    }
853
854    let total_candidates = nodes.len();
855    let sample = nodes
856        .into_iter()
857        .take(sample_limit)
858        .map(|sample| EmbeddingMigrationSampleDto {
859            sync_key: sample.sync_key,
860            session_id: display_session_id(&sample.session_id),
861            tier: sample.tier,
862            has_embedding: sample
863                .embedding
864                .as_ref()
865                .is_some_and(|values| !values.is_empty()),
866            embedding_model: sample.embedding_model,
867            embedding_dimensions: sample.embedding_dimensions,
868            embedded_at: sample.embedded_at,
869            updated_at: sample.updated_at,
870            context_summary: sample.context_summary,
871        })
872        .collect::<Vec<_>>();
873
874    Ok(Json(EmbeddingMigrationPreviewResultDto {
875        total_candidates,
876        sample,
877        provider_available: state.embedding_provider.is_some(),
878        provider_model: state
879            .embedding_provider
880            .as_ref()
881            .map(|provider| provider.model_name().to_string()),
882    }))
883}
884
885async fn run_embedding_migration_handler(
886    State(state): State<Arc<AppState>>,
887    headers: HeaderMap,
888    Json(request): Json<EmbeddingMigrationRunHttpRequest>,
889) -> ApiResult<EmbeddingMigrationRunResultDto> {
890    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
891    let mode = match request
892        .mode
893        .unwrap_or(EmbeddingMigrationModeHttp::MissingOnly)
894    {
895        EmbeddingMigrationModeHttp::MissingOnly => MemoryTransformOperation::EmbedBackfill,
896        EmbeddingMigrationModeHttp::ReindexAll => MemoryTransformOperation::ReindexEmbeddings,
897        EmbeddingMigrationModeHttp::Tags => MemoryTransformOperation::EmbedTagBackfill,
898        EmbeddingMigrationModeHttp::Both => MemoryTransformOperation::EmbedBackfill,
899    };
900    let dry_run = request.dry_run.unwrap_or(true);
901    let batch_size = request.batch_size.unwrap_or(100).clamp(1, 500);
902    let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
903    let (scope, filter, _sync_keys) = scoped_memory_filter(request.filter.clone(), &tenant);
904
905    let transform_service = transform_service(&state);
906
907    let mut result = transform_service
908        .execute(&MemoryTransformRequest {
909            scope: scope.clone(),
910            filter: filter.clone(),
911            operation: mode,
912            dry_run,
913            batch_size,
914            max_nodes,
915            provider_id: state
916                .embedding_provider
917                .as_ref()
918                .map(|_| "gateway-embedding".to_string()),
919            model: state
920                .embedding_provider
921                .as_ref()
922                .map(|provider| provider.model_name().to_string()),
923        })
924        .await
925        .map_err(internal_error)?;
926
927    if request.mode == Some(EmbeddingMigrationModeHttp::Both) && !dry_run {
928        let tag_result = transform_service
929            .execute(&MemoryTransformRequest {
930                scope,
931                filter,
932                operation: MemoryTransformOperation::EmbedTagBackfill,
933                dry_run: false,
934                batch_size,
935                max_nodes,
936                provider_id: state
937                    .embedding_provider
938                    .as_ref()
939                    .map(|_| "gateway-embedding".to_string()),
940                model: state
941                    .embedding_provider
942                    .as_ref()
943                    .map(|provider| provider.model_name().to_string()),
944            })
945            .await
946            .map_err(internal_error)?;
947        result.updated += tag_result.updated;
948        result.failed += tag_result.failed;
949        result.skipped += tag_result.skipped;
950        result.failures.extend(tag_result.failures);
951    }
952
953    Ok(Json(to_embedding_migration_run_dto(
954        result,
955        mode,
956        dry_run,
957        state
958            .embedding_provider
959            .as_ref()
960            .map(|provider| provider.model_name().to_string()),
961    )))
962}
963
964fn recall_service(state: &AppState) -> MemoryRecallService {
965    MemoryRecallService::new(state.node_store.clone())
966        .with_semantic_index(state.semantic_index.clone())
967}
968
969fn find_service(state: &AppState) -> MemoryFindService {
970    MemoryFindService::new(state.node_store.clone())
971        .with_semantic_index(state.semantic_index.clone())
972}
973
974fn evict_service(state: &AppState) -> MemoryEvictService {
975    MemoryEvictService::new(state.node_store.clone())
976        .with_semantic_index(state.semantic_index.clone())
977}
978
979fn graph_service(state: &AppState) -> MemoryGraphService {
980    MemoryGraphService::new(state.node_store.clone())
981        .with_semantic_index(state.semantic_index.clone())
982}
983
984fn transform_service(state: &AppState) -> MemoryTransformService {
985    let providers = build_gateway_provider_registry(state.embedding_provider.clone());
986    MemoryTransformService::new(state.node_store.clone(), providers)
987        .with_semantic_index(state.semantic_index.clone())
988}
989
990fn build_semantic_memory_filter(
991    semantic_tags: Option<Vec<String>>,
992    tags_contains: Option<Vec<String>>,
993    link_rel: Option<String>,
994    link_target: Option<String>,
995    links_to_ref: Option<String>,
996    tag_prefix: Option<String>,
997    has_semantic_links: Option<bool>,
998) -> MemoryFilter {
999    MemoryFilter {
1000        indexed_tags: semantic_tags,
1001        tags_contains,
1002        link_rel,
1003        link_target,
1004        links_to_ref,
1005        tag_prefix,
1006        has_semantic_links,
1007        ..Default::default()
1008    }
1009}
1010
1011fn bad_request(message: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
1012    (
1013        StatusCode::BAD_REQUEST,
1014        Json(ErrorResponse {
1015            error: message.into(),
1016        }),
1017    )
1018}
1019
1020fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
1021    (
1022        StatusCode::INTERNAL_SERVER_ERROR,
1023        Json(ErrorResponse {
1024            error: error.to_string(),
1025        }),
1026    )
1027}
1028
1029fn to_avec_dto(value: core_models::AvecState) -> AvecStateDto {
1030    AvecStateDto {
1031        stability: value.stability,
1032        friction: value.friction,
1033        logic: value.logic,
1034        autonomy: value.autonomy,
1035        psi: value.psi(),
1036    }
1037}
1038
1039fn to_node_dto(value: &core_models::SttpNode) -> SttpNodeDto {
1040    SttpNodeDto {
1041        raw: value.raw.clone(),
1042        session_id: display_session_id(&value.session_id),
1043        tier: value.tier.clone(),
1044        timestamp: value.timestamp,
1045        compression_depth: value.compression_depth,
1046        parent_node_id: value.parent_node_id.clone(),
1047        semantic_tags: value.semantic_tags.clone(),
1048        semantic_links: value.semantic_links.clone(),
1049        user_avec: to_avec_dto(value.user_avec),
1050        model_avec: to_avec_dto(value.model_avec),
1051        compression_avec: value.compression_avec.map(to_avec_dto),
1052        rho: value.rho,
1053        kappa: value.kappa,
1054        psi: value.psi,
1055        sync_key: value.sync_key.clone(),
1056        synthetic_id: graph_node_id(value),
1057    }
1058}
1059
1060fn to_mood_catalog_dto(result: core_models::MoodCatalogResult) -> MoodCatalogResultDto {
1061    MoodCatalogResultDto {
1062        presets: result
1063            .presets
1064            .into_iter()
1065            .map(|preset| MoodPresetDto {
1066                name: preset.name,
1067                description: preset.description,
1068                avec: to_avec_dto(preset.avec),
1069            })
1070            .collect(),
1071        apply_guide: result.apply_guide,
1072        swap_preview: result.swap_preview.map(|preview| MoodSwapPreviewDto {
1073            target_mood: preview.target_mood,
1074            blend: preview.blend,
1075            current: to_avec_dto(preview.current),
1076            target: to_avec_dto(preview.target),
1077            blended: to_avec_dto(preview.blended),
1078        }),
1079    }
1080}
1081
1082fn to_monthly_rollup_dto(result: core_models::MonthlyRollupResult) -> MonthlyRollupResultDto {
1083    MonthlyRollupResultDto {
1084        success: result.success,
1085        node_id: result.node_id,
1086        raw_node: result.raw_node,
1087        error: result.error,
1088        source_nodes: result.source_nodes,
1089        parent_reference: result.parent_reference,
1090        user_average: to_avec_dto(result.user_average),
1091        model_average: to_avec_dto(result.model_average),
1092        compression_average: to_avec_dto(result.compression_average),
1093        rho_range: to_numeric_range_dto(result.rho_range),
1094        kappa_range: to_numeric_range_dto(result.kappa_range),
1095        psi_range: to_numeric_range_dto(result.psi_range),
1096        rho_bands: to_confidence_bands_dto(result.rho_bands),
1097        kappa_bands: to_confidence_bands_dto(result.kappa_bands),
1098    }
1099}
1100
1101fn to_batch_rekey_dto(result: core_models::BatchRekeyResult) -> BatchRekeyResultDto {
1102    let updated_scopes = result.scopes.iter().filter(|scope| scope.applied).count();
1103    let conflict_scopes = result.scopes.iter().filter(|scope| scope.conflict).count();
1104
1105    BatchRekeyResultDto {
1106        dry_run: result.dry_run,
1107        requested_node_ids: result.requested_node_ids,
1108        resolved_node_ids: result.resolved_node_ids,
1109        missing_node_ids: result.missing_node_ids,
1110        scopes: result
1111            .scopes
1112            .into_iter()
1113            .map(|scope| ScopeRekeyResultDto {
1114                source_tenant_id: scope.source_tenant_id,
1115                source_session_id: display_session_id(&scope.source_session_id),
1116                target_tenant_id: scope.target_tenant_id,
1117                target_session_id: display_session_id(&scope.target_session_id),
1118                temporal_nodes: scope.temporal_nodes,
1119                calibrations: scope.calibrations,
1120                target_temporal_nodes: scope.target_temporal_nodes,
1121                target_calibrations: scope.target_calibrations,
1122                applied: scope.applied,
1123                conflict: scope.conflict,
1124                message: scope.message,
1125            })
1126            .collect(),
1127        temporal_nodes_updated: result.temporal_nodes_updated,
1128        calibrations_updated: result.calibrations_updated,
1129        updated_scopes,
1130        conflict_scopes,
1131    }
1132}
1133
1134fn scoped_memory_filter(
1135    request_filter: Option<EmbeddingMigrationFilterHttp>,
1136    tenant: &str,
1137) -> (MemoryScope, MemoryFilter, Option<Vec<String>>) {
1138    let filter = request_filter.unwrap_or(EmbeddingMigrationFilterHttp {
1139        session_id: None,
1140        from_utc: None,
1141        to_utc: None,
1142        tiers: None,
1143        has_embedding: None,
1144        embedding_model: None,
1145        sync_keys: None,
1146    });
1147
1148    (
1149        MemoryScope {
1150            tenant_id: None,
1151            session_ids: filter
1152                .session_id
1153                .map(|session_id| vec![scope_session_id(tenant, &session_id)]),
1154            tiers: filter.tiers,
1155            from_utc: filter.from_utc,
1156            to_utc: filter.to_utc,
1157        },
1158        MemoryFilter {
1159            has_embedding: filter.has_embedding,
1160            embedding_model: filter.embedding_model,
1161            ..Default::default()
1162        },
1163        filter.sync_keys,
1164    )
1165}
1166
1167fn build_gateway_provider_registry(
1168    provider: Option<Arc<dyn EmbeddingProvider>>,
1169) -> Arc<InMemoryAiProviderRegistry> {
1170    let mut registry = InMemoryAiProviderRegistry::new();
1171    if let Some(provider) = provider {
1172        registry.register(SttpEmbeddingProviderAdapter::new("gateway-embedding", provider));
1173    }
1174    Arc::new(registry)
1175}
1176
1177fn to_embedding_migration_run_dto(
1178    result: locus_sdk::domain::memory::MemoryTransformResult,
1179    mode: MemoryTransformOperation,
1180    dry_run: bool,
1181    provider_model: Option<String>,
1182) -> EmbeddingMigrationRunResultDto {
1183    EmbeddingMigrationRunResultDto {
1184        scanned: result.scanned,
1185        selected: result.selected,
1186        updated: result.updated,
1187        skipped: result.skipped,
1188        failed: result.failed,
1189        duplicate: result.duplicate,
1190        started_at: result.started_at,
1191        completed_at: result.completed_at,
1192        provider_model,
1193        dry_run,
1194        mode: match mode {
1195            MemoryTransformOperation::EmbedBackfill => "missing_only".to_string(),
1196            MemoryTransformOperation::ReindexEmbeddings => "reindex_all".to_string(),
1197            MemoryTransformOperation::EmbedTagBackfill => "tags".to_string(),
1198            MemoryTransformOperation::ReindexTagEmbeddings => "reindex_tags".to_string(),
1199        },
1200        failure_reasons: result.failures,
1201    }
1202}
1203
1204fn to_numeric_range_dto(value: NumericRange) -> NumericRangeDto {
1205    NumericRangeDto {
1206        min: value.min,
1207        max: value.max,
1208        average: value.average,
1209    }
1210}
1211
1212fn to_confidence_bands_dto(value: ConfidenceBandSummary) -> ConfidenceBandSummaryDto {
1213    ConfidenceBandSummaryDto {
1214        low: value.low,
1215        medium: value.medium,
1216        high: value.high,
1217    }
1218}
1219
1220#[derive(Clone)]
1221struct GrpcGatewayService {
1222    state: Arc<AppState>,
1223}
1224
1225impl GrpcGatewayService {
1226    fn new(state: Arc<AppState>) -> Self {
1227        Self { state }
1228    }
1229}
1230
1231#[tonic::async_trait]
1232impl proto::sttp_gateway_service_server::SttpGatewayService for GrpcGatewayService {
1233    async fn calibrate_session(
1234        &self,
1235        request: Request<proto::CalibrateSessionRequest>,
1236    ) -> Result<Response<proto::CalibrateSessionReply>, Status> {
1237        let tenant = resolve_grpc_tenant(request.metadata());
1238        let request = request.into_inner();
1239        let trigger = if request.trigger.trim().is_empty() {
1240            "manual"
1241        } else {
1242            &request.trigger
1243        };
1244        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1245
1246        let result = self
1247            .state
1248            .calibration
1249            .calibrate_async(
1250                &scoped_session_id,
1251                request.stability,
1252                request.friction,
1253                request.logic,
1254                request.autonomy,
1255                trigger,
1256            )
1257            .await
1258            .map_err(|err| Status::internal(err.to_string()))?;
1259
1260        let reply = proto::CalibrateSessionReply {
1261            previous_avec: Some(to_grpc_avec(result.previous_avec)),
1262            delta: result.delta,
1263            drift_classification: format!("{:?}", result.drift_classification),
1264            trigger: result.trigger,
1265            trigger_history: result.trigger_history,
1266            is_first_calibration: result.is_first_calibration,
1267        };
1268
1269        Ok(Response::new(reply))
1270    }
1271
1272    async fn store_context(
1273        &self,
1274        request: Request<proto::StoreContextRequest>,
1275    ) -> Result<Response<proto::StoreContextReply>, Status> {
1276        let tenant = resolve_grpc_tenant(request.metadata());
1277        let request = request.into_inner();
1278        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1279
1280        let result = self
1281            .state
1282            .store_context
1283            .store_async(&request.node, &scoped_session_id)
1284            .await;
1285
1286        let reply = proto::StoreContextReply {
1287            node_id: result.node_id,
1288            psi: result.psi,
1289            valid: result.valid,
1290            validation_error: result.validation_error,
1291        };
1292
1293        Ok(Response::new(reply))
1294    }
1295
1296    async fn get_context(
1297        &self,
1298        request: Request<proto::GetContextRequest>,
1299    ) -> Result<Response<proto::GetContextReply>, Status> {
1300        let tenant = resolve_grpc_tenant(request.metadata());
1301        let request = request.into_inner();
1302        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1303
1304        let query_embedding = resolve_query_embedding(
1305            self.state.embedding_provider.as_ref(),
1306            request.query_text.as_deref(),
1307            if request.query_embedding.is_empty() {
1308                None
1309            } else {
1310                Some(request.query_embedding.as_slice())
1311            },
1312        )
1313        .await;
1314
1315        let limit = if request.limit <= 0 {
1316            5
1317        } else {
1318            request.limit as usize
1319        };
1320        let tiers = normalize_request_tiers(Some(&request.tiers));
1321        let recall_service = MemoryRecallService::new(self.state.node_store.clone());
1322        let recall_result = recall_service
1323            .execute(&MemoryRecallRequest {
1324                scope: MemoryScope {
1325                    tenant_id: None,
1326                    session_ids: Some(vec![scoped_session_id]),
1327                    tiers,
1328                    from_utc: timestamp_from_proto_optional(request.from_utc)?,
1329                    to_utc: timestamp_from_proto_optional(request.to_utc)?,
1330                },
1331                page: MemoryPage {
1332                    limit,
1333                    cursor: None,
1334                },
1335                scoring: MemoryScoring {
1336                    alpha: request
1337                        .alpha
1338                        .unwrap_or(DEFAULT_HYBRID_ALPHA)
1339                        .clamp(0.0, 1.0),
1340                    beta: request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1341                    fallback_policy: FallbackPolicy::Never,
1342                    ..Default::default()
1343                },
1344                current_avec: Some(core_models::AvecState {
1345                    stability: request.stability,
1346                    friction: request.friction,
1347                    logic: request.logic,
1348                    autonomy: request.autonomy,
1349                }),
1350                query_text: request.query_text,
1351                query_embedding,
1352                ..Default::default()
1353            })
1354            .await
1355            .map_err(|err| Status::internal(err.to_string()))?;
1356
1357        let nodes = recall_result
1358            .nodes
1359            .iter()
1360            .cloned()
1361            .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1362            .collect::<Vec<_>>();
1363
1364        let reply = proto::GetContextReply {
1365            nodes: nodes.iter().map(to_grpc_node).collect(),
1366            retrieved: clamp_usize_to_i32(nodes.len()),
1367            psi_range: Some(to_grpc_psi_range(recall_result.psi_range)),
1368        };
1369
1370        Ok(Response::new(reply))
1371    }
1372
1373    async fn get_embedding_context(
1374        &self,
1375        request: Request<proto::GetEmbeddingContextRequest>,
1376    ) -> Result<Response<proto::GetContextReply>, Status> {
1377        let tenant = resolve_grpc_tenant(request.metadata());
1378        let request = request.into_inner();
1379        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1380
1381        let rag_embedding = resolve_query_embedding(
1382            self.state.embedding_provider.as_ref(),
1383            request.rag_query_text.as_deref(),
1384            if request.rag_embedding.is_empty() {
1385                None
1386            } else {
1387                Some(request.rag_embedding.as_slice())
1388            },
1389        )
1390        .await;
1391
1392        let avec_embedding = resolve_query_embedding(
1393            self.state.embedding_provider.as_ref(),
1394            request.avec_query_text.as_deref(),
1395            if request.avec_embedding.is_empty() {
1396                None
1397            } else {
1398                Some(request.avec_embedding.as_slice())
1399            },
1400        )
1401        .await;
1402
1403        let fused_embedding = fuse_weighted_embeddings(
1404            rag_embedding.as_deref(),
1405            avec_embedding.as_deref(),
1406            request.rag_weight.unwrap_or(0.7),
1407            request.avec_weight.unwrap_or(0.3),
1408        )
1409        .map_err(|(_, payload)| Status::invalid_argument(payload.0.error))?;
1410
1411        if fused_embedding.is_empty() {
1412            return Err(Status::invalid_argument(
1413                "Provide rag_embedding/rag_query_text and/or avec_embedding/avec_query_text",
1414            ));
1415        }
1416
1417        let limit = if request.limit <= 0 {
1418            5
1419        } else {
1420            request.limit as usize
1421        };
1422        let tiers = normalize_request_tiers(Some(&request.tiers));
1423
1424        let result = self
1425            .state
1426            .context_query
1427            .get_context_hybrid_scoped_filtered_async(
1428                Some(&scoped_session_id),
1429                request.stability,
1430                request.friction,
1431                request.logic,
1432                request.autonomy,
1433                timestamp_from_proto_optional(request.from_utc)?,
1434                timestamp_from_proto_optional(request.to_utc)?,
1435                tiers.as_deref(),
1436                Some(fused_embedding.as_slice()),
1437                request
1438                    .alpha
1439                    .unwrap_or(DEFAULT_HYBRID_ALPHA)
1440                    .clamp(0.0, 1.0),
1441                request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1442                limit,
1443            )
1444            .await;
1445
1446        let nodes = result
1447            .nodes
1448            .iter()
1449            .cloned()
1450            .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1451            .collect::<Vec<_>>();
1452
1453        let reply = proto::GetContextReply {
1454            nodes: nodes.iter().map(to_grpc_node).collect(),
1455            retrieved: clamp_usize_to_i32(nodes.len()),
1456            psi_range: Some(to_grpc_psi_range(result.psi_range)),
1457        };
1458
1459        Ok(Response::new(reply))
1460    }
1461
1462    async fn list_nodes(
1463        &self,
1464        request: Request<proto::ListNodesRequest>,
1465    ) -> Result<Response<proto::ListNodesReply>, Status> {
1466        let tenant = resolve_grpc_tenant(request.metadata());
1467        let request = request.into_inner();
1468        let requested_limit = if request.limit <= 0 {
1469            50
1470        } else {
1471            request.limit as usize
1472        }
1473        .clamp(1, TENANT_SCAN_LIMIT);
1474        let scoped_session_filter = request
1475            .session_id
1476            .as_deref()
1477            .map(|session_id| scope_session_id(&tenant, session_id));
1478        let backend_limit = if scoped_session_filter.is_some() {
1479            requested_limit
1480        } else {
1481            TENANT_SCAN_LIMIT
1482        };
1483
1484        let find_service = MemoryFindService::new(self.state.node_store.clone());
1485        let result = find_service
1486            .execute(&MemoryFindRequest {
1487                scope: MemoryScope {
1488                    tenant_id: None,
1489                    session_ids: scoped_session_filter.map(|session| vec![session]),
1490                    tiers: None,
1491                    from_utc: None,
1492                    to_utc: None,
1493                },
1494                page: MemoryPage {
1495                    limit: backend_limit,
1496                    cursor: None,
1497                },
1498                ..Default::default()
1499            })
1500            .await
1501            .map_err(|err| Status::internal(err.to_string()))?;
1502
1503        let nodes = result
1504            .nodes
1505            .into_iter()
1506            .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1507            .take(requested_limit)
1508            .collect::<Vec<_>>();
1509
1510        let reply = proto::ListNodesReply {
1511            nodes: nodes.iter().map(to_grpc_node).collect(),
1512            retrieved: clamp_usize_to_i32(nodes.len()),
1513        };
1514
1515        Ok(Response::new(reply))
1516    }
1517
1518    async fn get_moods(
1519        &self,
1520        request: Request<proto::GetMoodsRequest>,
1521    ) -> Result<Response<proto::GetMoodsReply>, Status> {
1522        let request = request.into_inner();
1523        let result = self.state.mood_catalog.get(
1524            request.target_mood.as_deref(),
1525            request.blend,
1526            request.current_stability,
1527            request.current_friction,
1528            request.current_logic,
1529            request.current_autonomy,
1530        );
1531
1532        let reply = proto::GetMoodsReply {
1533            presets: result
1534                .presets
1535                .into_iter()
1536                .map(|preset| proto::MoodPreset {
1537                    name: preset.name,
1538                    description: preset.description,
1539                    avec: Some(to_grpc_avec(preset.avec)),
1540                })
1541                .collect(),
1542            apply_guide: result.apply_guide,
1543            swap_preview: result.swap_preview.map(|preview| proto::MoodSwapPreview {
1544                target_mood: preview.target_mood,
1545                blend: preview.blend,
1546                current: Some(to_grpc_avec(preview.current)),
1547                target: Some(to_grpc_avec(preview.target)),
1548                blended: Some(to_grpc_avec(preview.blended)),
1549            }),
1550        };
1551
1552        Ok(Response::new(reply))
1553    }
1554
1555    async fn batch_rekey(
1556        &self,
1557        request: Request<proto::BatchRekeyRequest>,
1558    ) -> Result<Response<proto::BatchRekeyReply>, Status> {
1559        let metadata_tenant = resolve_grpc_tenant(request.metadata());
1560        let request = request.into_inner();
1561
1562        if request.node_ids.is_empty() {
1563            return Err(Status::invalid_argument(
1564                "node_ids must contain at least one value",
1565            ));
1566        }
1567
1568        if request.target_session_id.trim().is_empty() {
1569            return Err(Status::invalid_argument(
1570                "target_session_id cannot be empty",
1571            ));
1572        }
1573
1574        let target_tenant = request
1575            .target_tenant_id
1576            .as_deref()
1577            .and_then(normalize_tenant_value)
1578            .unwrap_or(metadata_tenant);
1579        let scoped_target_session =
1580            scope_session_id(&target_tenant, request.target_session_id.trim());
1581
1582        let result = self
1583            .state
1584            .rekey_scope
1585            .rekey_async(
1586                request.node_ids,
1587                &target_tenant,
1588                &scoped_target_session,
1589                request.dry_run.unwrap_or(true),
1590                request.allow_merge.unwrap_or(false),
1591            )
1592            .await
1593            .map_err(|err| Status::internal(err.to_string()))?;
1594
1595        Ok(Response::new(to_grpc_batch_rekey_reply(result)))
1596    }
1597
1598    async fn create_monthly_rollup(
1599        &self,
1600        request: Request<proto::CreateMonthlyRollupRequest>,
1601    ) -> Result<Response<proto::CreateMonthlyRollupReply>, Status> {
1602        let tenant = resolve_grpc_tenant(request.metadata());
1603        let request = request.into_inner();
1604
1605        let start_utc = timestamp_from_proto(request.start_utc)?;
1606        let end_utc = timestamp_from_proto(request.end_utc)?;
1607
1608        let monthly_request = MonthlyRollupRequest {
1609            session_id: scope_session_id(&tenant, &request.session_id),
1610            start_utc,
1611            end_utc,
1612            source_session_id: request
1613                .source_session_id
1614                .map(|session_id| scope_session_id(&tenant, &session_id)),
1615            parent_node_id: request.parent_node_id,
1616            persist: request.persist,
1617            limit: if request.limit <= 0 {
1618                5000
1619            } else {
1620                request.limit as usize
1621            },
1622        };
1623
1624        let result = self
1625            .state
1626            .monthly_rollup
1627            .create_async(monthly_request)
1628            .await;
1629
1630        let reply = proto::CreateMonthlyRollupReply {
1631            success: result.success,
1632            node_id: result.node_id,
1633            raw_node: result.raw_node,
1634            error: result.error,
1635            source_nodes: clamp_usize_to_i32(result.source_nodes),
1636            parent_reference: result.parent_reference,
1637            user_average: Some(to_grpc_avec(result.user_average)),
1638            model_average: Some(to_grpc_avec(result.model_average)),
1639            compression_average: Some(to_grpc_avec(result.compression_average)),
1640            rho_range: Some(to_grpc_numeric_range(result.rho_range)),
1641            kappa_range: Some(to_grpc_numeric_range(result.kappa_range)),
1642            psi_range: Some(to_grpc_numeric_range(result.psi_range)),
1643            rho_bands: Some(to_grpc_confidence_bands(result.rho_bands)),
1644            kappa_bands: Some(to_grpc_confidence_bands(result.kappa_bands)),
1645        };
1646
1647        Ok(Response::new(reply))
1648    }
1649}
1650
1651fn to_grpc_avec(value: core_models::AvecState) -> proto::AvecState {
1652    proto::AvecState {
1653        stability: value.stability,
1654        friction: value.friction,
1655        logic: value.logic,
1656        autonomy: value.autonomy,
1657        psi: value.psi(),
1658    }
1659}
1660
1661fn to_grpc_node(value: &core_models::SttpNode) -> proto::SttpNode {
1662    proto::SttpNode {
1663        raw: value.raw.clone(),
1664        session_id: display_session_id(&value.session_id),
1665        tier: value.tier.clone(),
1666        timestamp: Some(timestamp_to_proto(value.timestamp)),
1667        compression_depth: value.compression_depth,
1668        parent_node_id: value.parent_node_id.clone(),
1669        user_avec: Some(to_grpc_avec(value.user_avec)),
1670        model_avec: Some(to_grpc_avec(value.model_avec)),
1671        compression_avec: value.compression_avec.map(to_grpc_avec),
1672        rho: value.rho,
1673        kappa: value.kappa,
1674        psi: value.psi,
1675        semantic_tags: value.semantic_tags.clone().unwrap_or_default(),
1676        semantic_links: value
1677            .semantic_links
1678            .clone()
1679            .unwrap_or_default()
1680            .into_iter()
1681            .map(|link| proto::SemanticLink {
1682                rel: link.rel,
1683                target: link.target,
1684                confidence: link.confidence,
1685            })
1686            .collect(),
1687    }
1688}
1689
1690fn to_grpc_psi_range(value: PsiRange) -> proto::PsiRange {
1691    proto::PsiRange {
1692        min: value.min,
1693        max: value.max,
1694        average: value.average,
1695    }
1696}
1697
1698fn to_grpc_numeric_range(value: NumericRange) -> proto::NumericRange {
1699    proto::NumericRange {
1700        min: value.min,
1701        max: value.max,
1702        average: value.average,
1703    }
1704}
1705
1706fn to_grpc_confidence_bands(value: ConfidenceBandSummary) -> proto::ConfidenceBandSummary {
1707    proto::ConfidenceBandSummary {
1708        low: clamp_usize_to_i32(value.low),
1709        medium: clamp_usize_to_i32(value.medium),
1710        high: clamp_usize_to_i32(value.high),
1711    }
1712}
1713
1714fn to_grpc_scope_rekey_result(value: core_models::ScopeRekeyResult) -> proto::ScopeRekeyResult {
1715    proto::ScopeRekeyResult {
1716        source_tenant_id: value.source_tenant_id,
1717        source_session_id: display_session_id(&value.source_session_id),
1718        target_tenant_id: value.target_tenant_id,
1719        target_session_id: display_session_id(&value.target_session_id),
1720        temporal_nodes: clamp_usize_to_i32(value.temporal_nodes),
1721        calibrations: clamp_usize_to_i32(value.calibrations),
1722        target_temporal_nodes: clamp_usize_to_i32(value.target_temporal_nodes),
1723        target_calibrations: clamp_usize_to_i32(value.target_calibrations),
1724        applied: value.applied,
1725        conflict: value.conflict,
1726        message: value.message,
1727    }
1728}
1729
1730fn to_grpc_batch_rekey_reply(value: core_models::BatchRekeyResult) -> proto::BatchRekeyReply {
1731    let updated_scopes = value.scopes.iter().filter(|scope| scope.applied).count();
1732    let conflict_scopes = value.scopes.iter().filter(|scope| scope.conflict).count();
1733
1734    proto::BatchRekeyReply {
1735        dry_run: value.dry_run,
1736        requested_node_ids: clamp_usize_to_i32(value.requested_node_ids),
1737        resolved_node_ids: clamp_usize_to_i32(value.resolved_node_ids),
1738        missing_node_ids: value.missing_node_ids,
1739        scopes: value
1740            .scopes
1741            .into_iter()
1742            .map(to_grpc_scope_rekey_result)
1743            .collect(),
1744        temporal_nodes_updated: clamp_usize_to_i32(value.temporal_nodes_updated),
1745        calibrations_updated: clamp_usize_to_i32(value.calibrations_updated),
1746        updated_scopes: clamp_usize_to_i32(updated_scopes),
1747        conflict_scopes: clamp_usize_to_i32(conflict_scopes),
1748    }
1749}
1750
1751fn timestamp_to_proto(value: DateTime<Utc>) -> prost_types::Timestamp {
1752    prost_types::Timestamp {
1753        seconds: value.timestamp(),
1754        nanos: value.timestamp_subsec_nanos() as i32,
1755    }
1756}
1757
1758fn timestamp_from_proto(value: Option<prost_types::Timestamp>) -> Result<DateTime<Utc>, Status> {
1759    let value = value.ok_or_else(|| Status::invalid_argument("missing timestamp"))?;
1760    DateTime::<Utc>::from_timestamp(value.seconds, value.nanos as u32)
1761        .ok_or_else(|| Status::invalid_argument("invalid timestamp"))
1762}
1763
1764fn timestamp_from_proto_optional(
1765    value: Option<prost_types::Timestamp>,
1766) -> Result<Option<DateTime<Utc>>, Status> {
1767    value
1768        .map(|timestamp| timestamp_from_proto(Some(timestamp)))
1769        .transpose()
1770}
1771
1772fn normalize_request_tiers(tiers: Option<&[String]>) -> Option<Vec<String>> {
1773    let normalized = tiers
1774        .unwrap_or(&[])
1775        .iter()
1776        .map(|tier| tier.trim().to_ascii_lowercase())
1777        .filter(|tier| !tier.is_empty())
1778        .collect::<Vec<_>>();
1779
1780    if normalized.is_empty() {
1781        None
1782    } else {
1783        Some(normalized)
1784    }
1785}
1786
1787fn clamp_usize_to_i32(value: usize) -> i32 {
1788    if value > i32::MAX as usize {
1789        i32::MAX
1790    } else {
1791        value as i32
1792    }
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797    use super::*;
1798    use crate::gateway::proto::sttp_gateway_service_server::SttpGatewayService;
1799    use crate::gateway_args::{EmbeddingsProviderKind, GatewayBackend};
1800    use crate::providers::parse_avec_state_from_text;
1801    use crate::tenant::session_belongs_to_tenant;
1802    use locus_core_rs::storage::{
1803        SurrealDbEndpointsSettings, SurrealDbRuntimeOptions, SurrealDbSettings,
1804    };
1805
1806    fn sample_node(session_id: &str) -> String {
1807        format!(
1808            r#"
1809⊕⟨ {{ trigger: manual, response_format: temporal_node, origin_session: "{session_id}", compression_depth: 1, parent_node: null, prime: {{ attractor_config: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70 }}, context_summary: "gateway test", relevant_tier: raw, retrieval_budget: 3 }} }} ⟩
1810⦿⟨ {{ timestamp: "2026-03-05T06:30:00Z", tier: raw, session_id: "{session_id}", user_avec: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70, psi: 2.60 }}, model_avec: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70, psi: 2.60 }} }} ⟩
1811◈⟨ {{ test(.99): "gateway parser check" }} ⟩
1812⍉⟨ {{ rho: 0.96, kappa: 0.94, psi: 2.60, compression_avec: {{ stability: 0.85, friction: 0.25, logic: 0.80, autonomy: 0.70, psi: 2.60 }} }} ⟩
1813"#
1814        )
1815    }
1816
1817    fn surreal_test_args() -> GatewayArgs {
1818        GatewayArgs {
1819            http_port: 8080,
1820            grpc_port: 8081,
1821            backend: GatewayBackend::Surreal,
1822            root_dir_name: ".locus-gateway".to_string(),
1823            remote: true,
1824            cors_enabled: true,
1825            cors_allowed_origins: "*".to_string(),
1826            surreal_embedded_endpoint: None,
1827            surreal_remote_endpoint: Some("ws://127.0.0.1:8000/rpc".to_string()),
1828            surreal_namespace: "entasis".to_string(),
1829            surreal_database: "locus_gateway".to_string(),
1830            surreal_user: "root".to_string(),
1831            surreal_password: "root".to_string(),
1832            embeddings_enabled: false,
1833            embeddings_provider: EmbeddingsProviderKind::Ollama,
1834            embeddings_endpoint: "http://127.0.0.1:11434/api/embeddings".to_string(),
1835            embeddings_model: "sttp-encoder".to_string(),
1836            embeddings_repo: "sentence-transformers/all-MiniLM-L6-v2".to_string(),
1837            avec_scoring_enabled: false,
1838            avec_scoring_endpoint: "http://127.0.0.1:11434/api/chat".to_string(),
1839            avec_scoring_model: "qwen2.5:0.5b".to_string(),
1840        }
1841    }
1842
1843    #[test]
1844    fn parse_avec_state_accepts_plain_json() {
1845        let value = parse_avec_state_from_text(
1846            r#"{"stability":0.8,"friction":0.2,"logic":0.9,"autonomy":0.7}"#,
1847        )
1848        .expect("plain JSON should parse");
1849
1850        assert!((value.stability - 0.8).abs() < f32::EPSILON);
1851        assert!((value.friction - 0.2).abs() < f32::EPSILON);
1852        assert!((value.logic - 0.9).abs() < f32::EPSILON);
1853        assert!((value.autonomy - 0.7).abs() < f32::EPSILON);
1854    }
1855
1856    #[test]
1857    fn parse_avec_state_extracts_embedded_json() {
1858        let value = parse_avec_state_from_text(
1859            "Here is the answer: {\"stability\":1.1,\"friction\":-0.1,\"logic\":0.5,\"autonomy\":0.6}",
1860        )
1861        .expect("embedded JSON should parse");
1862
1863        assert!((value.stability - 1.0).abs() < f32::EPSILON);
1864        assert!((value.friction - 0.0).abs() < f32::EPSILON);
1865        assert!((value.logic - 0.5).abs() < f32::EPSILON);
1866        assert!((value.autonomy - 0.6).abs() < f32::EPSILON);
1867    }
1868
1869    #[tokio::test]
1870    async fn score_avec_returns_bad_request_when_disabled() {
1871        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1872
1873        let err = score_avec_handler(
1874            State(state),
1875            HeaderMap::new(),
1876            Json(ScoreAvecHttpRequest {
1877                text: "hello world".to_string(),
1878                tenant_id: None,
1879            }),
1880        )
1881        .await
1882        .expect_err("scoring should fail when disabled");
1883
1884        assert_eq!(err.0, StatusCode::BAD_REQUEST);
1885    }
1886
1887    #[test]
1888    fn surreal_runtime_options_are_derived_from_gateway_args() {
1889        let args = surreal_test_args();
1890        let mut settings = SurrealDbSettings::default();
1891        settings.endpoints = SurrealDbEndpointsSettings {
1892            embedded: args.surreal_embedded_endpoint.clone(),
1893            remote: args.surreal_remote_endpoint.clone(),
1894        };
1895        settings.namespace = args.surreal_namespace.clone();
1896        settings.database = args.surreal_database.clone();
1897
1898        let runtime_args = vec!["--remote".to_string()];
1899        let runtime = SurrealDbRuntimeOptions::from_args(
1900            &runtime_args,
1901            &settings,
1902            Some(args.root_dir_name.as_str()),
1903        )
1904        .expect("runtime options should be computed");
1905
1906        assert!(runtime.use_remote);
1907        assert_eq!(runtime.endpoint, "ws://127.0.0.1:8000/rpc");
1908        assert_eq!(runtime.namespace, "entasis");
1909        assert_eq!(runtime.database, "locus_gateway");
1910    }
1911
1912    #[test]
1913    fn tenant_scoping_is_applied_only_for_non_default_tenant() {
1914        let scoped = scope_session_id("acme", "session-1");
1915        assert_eq!(scoped, "tenant:acme::session:session-1");
1916        assert!(session_belongs_to_tenant(&scoped, "acme"));
1917        assert!(!session_belongs_to_tenant(&scoped, "default"));
1918        assert_eq!(display_session_id(&scoped), "session-1");
1919
1920        let legacy = scope_session_id("default", "session-1");
1921        assert_eq!(legacy, "session-1");
1922        assert!(session_belongs_to_tenant(&legacy, "default"));
1923    }
1924
1925    #[tokio::test]
1926    async fn http_calibrate_defaults_trigger_to_manual() {
1927        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1928
1929        let request = CalibrateSessionHttpRequest {
1930            session_id: "http-calibrate-session".to_string(),
1931            tenant_id: None,
1932            stability: 0.8,
1933            friction: 0.2,
1934            logic: 0.8,
1935            autonomy: 0.7,
1936            trigger: None,
1937        };
1938
1939        let Json(reply) = calibrate_handler(State(state), HeaderMap::new(), Json(request))
1940            .await
1941            .expect("calibrate should succeed");
1942
1943        assert_eq!(reply.trigger, "manual");
1944        assert!(reply.is_first_calibration);
1945    }
1946
1947    #[tokio::test]
1948    async fn http_store_then_get_context_roundtrip() {
1949        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1950        let session_id = "http-store-session";
1951
1952        let Json(store_reply) = store_context_handler(
1953            State(state.clone()),
1954            HeaderMap::new(),
1955            Json(StoreContextHttpRequest {
1956                node: sample_node(session_id),
1957                session_id: session_id.to_string(),
1958                tenant_id: None,
1959            }),
1960        )
1961        .await
1962        .expect("store should succeed");
1963
1964        assert!(store_reply.valid);
1965        assert!(!store_reply.node_id.is_empty());
1966
1967        let Json(context_reply) = get_context_handler(
1968            State(state),
1969            HeaderMap::new(),
1970            Json(GetContextHttpRequest {
1971                session_id: session_id.to_string(),
1972                tenant_id: None,
1973                stability: 0.85,
1974                friction: 0.25,
1975                logic: 0.80,
1976                autonomy: 0.70,
1977                limit: Some(5),
1978                from_utc: None,
1979                to_utc: None,
1980                tiers: None,
1981                query_text: Some("gateway parser check".to_string()),
1982                query_embedding: None,
1983                alpha: None,
1984                beta: None,
1985                gamma: None,
1986                semantic_tags: None,
1987                tags_contains: None,
1988                link_rel: None,
1989                link_target: None,
1990                links_to_ref: None,
1991                tag_prefix: None,
1992                has_semantic_links: None,
1993            }),
1994        )
1995        .await
1996        .expect("get_context should succeed");
1997
1998        assert!(context_reply.retrieved >= 1);
1999        assert!(
2000            context_reply
2001                .nodes
2002                .iter()
2003                .any(|node| node.session_id == session_id)
2004        );
2005    }
2006
2007    #[tokio::test]
2008    async fn http_embedding_context_roundtrip_with_direct_embeddings() {
2009        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2010        let session_id = "http-embedding-context-session";
2011
2012        let Json(store_reply) = store_context_handler(
2013            State(state.clone()),
2014            HeaderMap::new(),
2015            Json(StoreContextHttpRequest {
2016                node: sample_node(session_id),
2017                session_id: session_id.to_string(),
2018                tenant_id: None,
2019            }),
2020        )
2021        .await
2022        .expect("store should succeed");
2023
2024        assert!(store_reply.valid);
2025
2026        let Json(context_reply) = get_embedding_context_handler(
2027            State(state),
2028            HeaderMap::new(),
2029            Json(GetEmbeddingContextHttpRequest {
2030                session_id: session_id.to_string(),
2031                tenant_id: None,
2032                stability: 0.85,
2033                friction: 0.25,
2034                logic: 0.80,
2035                autonomy: 0.70,
2036                limit: Some(5),
2037                from_utc: None,
2038                to_utc: None,
2039                tiers: None,
2040                rag_query_text: None,
2041                rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2042                avec_query_text: None,
2043                avec_embedding: Some(vec![0.2, 0.2, 0.2]),
2044                rag_weight: Some(0.7),
2045                avec_weight: Some(0.3),
2046                alpha: Some(0.65),
2047                beta: Some(0.35),
2048                tag_weight: None,
2049                semantic_tags: None,
2050                link_rel: None,
2051                link_target: None,
2052            }),
2053        )
2054        .await
2055        .expect("embedding context should succeed");
2056
2057        assert!(context_reply.retrieved >= 1);
2058    }
2059
2060    #[tokio::test]
2061    async fn http_embedding_context_rejects_mismatched_embedding_dimensions() {
2062        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2063
2064        let err = get_embedding_context_handler(
2065            State(state),
2066            HeaderMap::new(),
2067            Json(GetEmbeddingContextHttpRequest {
2068                session_id: "session".to_string(),
2069                tenant_id: None,
2070                stability: 0.8,
2071                friction: 0.2,
2072                logic: 0.8,
2073                autonomy: 0.7,
2074                limit: Some(5),
2075                from_utc: None,
2076                to_utc: None,
2077                tiers: None,
2078                rag_query_text: None,
2079                rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2080                avec_query_text: None,
2081                avec_embedding: Some(vec![0.3, 0.2]),
2082                rag_weight: Some(0.7),
2083                avec_weight: Some(0.3),
2084                alpha: None,
2085                beta: None,
2086                tag_weight: None,
2087                semantic_tags: None,
2088                link_rel: None,
2089                link_target: None,
2090            }),
2091        )
2092        .await
2093        .expect_err("mismatched dimensions should fail");
2094
2095        assert_eq!(err.0, StatusCode::BAD_REQUEST);
2096    }
2097
2098    #[tokio::test]
2099    async fn http_evict_dry_run_then_delete_roundtrip() {
2100        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2101        let session_id = "http-evict-session";
2102
2103        let _ = store_context_handler(
2104            State(state.clone()),
2105            HeaderMap::new(),
2106            Json(StoreContextHttpRequest {
2107                node: sample_node(session_id),
2108                session_id: session_id.to_string(),
2109                tenant_id: None,
2110            }),
2111        )
2112        .await
2113        .expect("store should succeed");
2114
2115        let nodes_before = list_nodes_handler(
2116            State(state.clone()),
2117            HeaderMap::new(),
2118            Query(ListNodesQuery {
2119                tenant_id: None,
2120                session_id: Some(session_id.to_string()),
2121                limit: Some(10),
2122                semantic_tags: None,
2123                tags_contains: None,
2124                link_rel: None,
2125                link_target: None,
2126                links_to_ref: None,
2127                tag_prefix: None,
2128                has_semantic_links: None,
2129            }),
2130        )
2131        .await
2132        .expect("list should succeed");
2133        assert!(nodes_before.retrieved >= 1);
2134        let sync_key = nodes_before.nodes[0].sync_key.clone();
2135
2136        let Json(preview) = evict_handler(
2137            State(state.clone()),
2138            HeaderMap::new(),
2139            Json(EvictHttpRequest {
2140                tenant_id: None,
2141                session_id: session_id.to_string(),
2142                mode: None,
2143                sync_keys: Some(vec![sync_key.clone()]),
2144                node_ids: None,
2145                semantic_tags: None,
2146                tags_contains: None,
2147                link_rel: None,
2148                link_target: None,
2149                links_to_ref: None,
2150                tag_prefix: None,
2151                has_semantic_links: None,
2152                purge_session: None,
2153                dry_run: Some(true),
2154                force: Some(true),
2155                max_nodes: None,
2156                include_calibration: None,
2157                include_checkpoints: None,
2158                tiers: None,
2159            }),
2160        )
2161        .await
2162        .expect("dry-run evict should succeed");
2163
2164        assert_eq!(preview.deleted, 1);
2165        assert_eq!(preview.would_delete, vec![sync_key.clone()]);
2166
2167        let nodes_after_preview = list_nodes_handler(
2168            State(state.clone()),
2169            HeaderMap::new(),
2170            Query(ListNodesQuery {
2171                tenant_id: None,
2172                session_id: Some(session_id.to_string()),
2173                limit: Some(10),
2174                semantic_tags: None,
2175                tags_contains: None,
2176                link_rel: None,
2177                link_target: None,
2178                links_to_ref: None,
2179                tag_prefix: None,
2180                has_semantic_links: None,
2181            }),
2182        )
2183        .await
2184        .expect("list should succeed");
2185        assert_eq!(nodes_after_preview.retrieved, nodes_before.retrieved);
2186
2187        let Json(applied) = evict_handler(
2188            State(state.clone()),
2189            HeaderMap::new(),
2190            Json(EvictHttpRequest {
2191                tenant_id: None,
2192                session_id: session_id.to_string(),
2193                mode: None,
2194                sync_keys: Some(vec![sync_key]),
2195                node_ids: None,
2196                semantic_tags: None,
2197                tags_contains: None,
2198                link_rel: None,
2199                link_target: None,
2200                links_to_ref: None,
2201                tag_prefix: None,
2202                has_semantic_links: None,
2203                purge_session: None,
2204                dry_run: Some(false),
2205                force: Some(true),
2206                max_nodes: None,
2207                include_calibration: None,
2208                include_checkpoints: None,
2209                tiers: None,
2210            }),
2211        )
2212        .await
2213        .expect("evict should succeed");
2214        assert_eq!(applied.deleted, 1);
2215
2216        let nodes_after = list_nodes_handler(
2217            State(state),
2218            HeaderMap::new(),
2219            Query(ListNodesQuery {
2220                tenant_id: None,
2221                session_id: Some(session_id.to_string()),
2222                limit: Some(10),
2223                semantic_tags: None,
2224                tags_contains: None,
2225                link_rel: None,
2226                link_target: None,
2227                links_to_ref: None,
2228                tag_prefix: None,
2229                has_semantic_links: None,
2230            }),
2231        )
2232        .await
2233        .expect("list should succeed");
2234        assert_eq!(nodes_after.retrieved, 0);
2235    }
2236
2237    #[tokio::test]
2238    async fn grpc_service_roundtrip_for_calibrate_store_and_list() {
2239        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2240        let service = GrpcGatewayService::new(state);
2241        let session_id = "grpc-store-session";
2242
2243        let calibrate_reply = service
2244            .calibrate_session(Request::new(proto::CalibrateSessionRequest {
2245                session_id: session_id.to_string(),
2246                stability: 0.8,
2247                friction: 0.2,
2248                logic: 0.8,
2249                autonomy: 0.7,
2250                trigger: String::new(),
2251            }))
2252            .await
2253            .expect("gRPC calibrate should succeed")
2254            .into_inner();
2255
2256        assert_eq!(calibrate_reply.trigger, "manual");
2257
2258        let store_reply = service
2259            .store_context(Request::new(proto::StoreContextRequest {
2260                node: sample_node(session_id),
2261                session_id: session_id.to_string(),
2262            }))
2263            .await
2264            .expect("gRPC store should succeed")
2265            .into_inner();
2266
2267        assert!(store_reply.valid);
2268
2269        let rekey_reply = service
2270            .batch_rekey(Request::new(proto::BatchRekeyRequest {
2271                node_ids: vec![store_reply.node_id.clone()],
2272                target_session_id: session_id.to_string(),
2273                target_tenant_id: Some("default".to_string()),
2274                dry_run: Some(true),
2275                allow_merge: Some(false),
2276            }))
2277            .await
2278            .expect("gRPC batch_rekey should succeed")
2279            .into_inner();
2280
2281        assert_eq!(rekey_reply.requested_node_ids, 1);
2282        assert_eq!(rekey_reply.resolved_node_ids, 1);
2283        assert!(rekey_reply.scopes.iter().any(|scope| !scope.applied));
2284
2285        let list_reply = service
2286            .list_nodes(Request::new(proto::ListNodesRequest {
2287                limit: 50,
2288                session_id: Some(session_id.to_string()),
2289            }))
2290            .await
2291            .expect("gRPC list_nodes should succeed")
2292            .into_inner();
2293
2294        assert!(list_reply.retrieved >= 1);
2295        assert!(
2296            list_reply
2297                .nodes
2298                .iter()
2299                .any(|node| node.session_id == session_id)
2300        );
2301
2302        let context_reply = service
2303            .get_context(Request::new(proto::GetContextRequest {
2304                session_id: session_id.to_string(),
2305                stability: 0.8,
2306                friction: 0.2,
2307                logic: 0.8,
2308                autonomy: 0.7,
2309                limit: 5,
2310                query_text: None,
2311                query_embedding: vec![0.1, 0.2, 0.3],
2312                alpha: Some(0.7),
2313                beta: Some(0.3),
2314                from_utc: None,
2315                to_utc: None,
2316                tiers: Vec::new(),
2317            }))
2318            .await
2319            .expect("gRPC get_context should succeed")
2320            .into_inner();
2321
2322        assert!(context_reply.retrieved >= 1);
2323
2324        let embedding_context_reply = service
2325            .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2326                session_id: session_id.to_string(),
2327                stability: 0.8,
2328                friction: 0.2,
2329                logic: 0.8,
2330                autonomy: 0.7,
2331                limit: 5,
2332                rag_query_text: None,
2333                rag_embedding: vec![0.1, 0.2, 0.3],
2334                avec_query_text: None,
2335                avec_embedding: vec![0.2, 0.2, 0.2],
2336                rag_weight: Some(0.7),
2337                avec_weight: Some(0.3),
2338                alpha: Some(0.7),
2339                beta: Some(0.3),
2340                from_utc: None,
2341                to_utc: None,
2342                tiers: Vec::new(),
2343            }))
2344            .await
2345            .expect("gRPC get_embedding_context should succeed")
2346            .into_inner();
2347
2348        assert!(embedding_context_reply.retrieved >= 1);
2349
2350        let err = service
2351            .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2352                session_id: session_id.to_string(),
2353                stability: 0.8,
2354                friction: 0.2,
2355                logic: 0.8,
2356                autonomy: 0.7,
2357                limit: 5,
2358                rag_query_text: None,
2359                rag_embedding: vec![0.1, 0.2, 0.3],
2360                avec_query_text: None,
2361                avec_embedding: vec![0.2, 0.2],
2362                rag_weight: Some(0.7),
2363                avec_weight: Some(0.3),
2364                alpha: None,
2365                beta: None,
2366                from_utc: None,
2367                to_utc: None,
2368                tiers: Vec::new(),
2369            }))
2370            .await
2371            .expect_err("gRPC get_embedding_context should reject invalid dimensions");
2372
2373        assert_eq!(err.code(), Status::invalid_argument("x").code());
2374    }
2375}