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::contracts::EmbeddingProvider;
20use locus_core_rs::domain::models::{
21    self as core_models, ConfidenceBandSummary, MonthlyRollupRequest, NumericRange, PsiRange,
22};
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, MemoryScope,
32    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::*;
43#[cfg(test)]
44use crate::orchestration::build_in_memory_state;
45use crate::orchestration::{build_state, parse_cors_allowed_origins, shutdown_signal};
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::OnEmpty,
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.include_calibration.unwrap_or(purge_session);
653    let include_checkpoints = request.include_checkpoints.unwrap_or(purge_session);
654
655    let result = evict_service(&state)
656        .execute(&MemoryEvictRequest {
657            mode,
658            scope: MemoryScope {
659                tenant_id: Some(tenant),
660                session_ids: Some(vec![scoped_session]),
661                tiers: normalize_request_tiers(request.tiers.as_deref()),
662                from_utc: None,
663                to_utc: None,
664            },
665            filter: build_semantic_memory_filter(
666                request.semantic_tags,
667                request.tags_contains,
668                request.link_rel,
669                request.link_target,
670                request.links_to_ref,
671                request.tag_prefix,
672                request.has_semantic_links,
673            ),
674            sync_keys: request.sync_keys,
675            node_ids: request.node_ids,
676            dry_run: request.dry_run.unwrap_or(false),
677            force: request.force.unwrap_or(false),
678            max_nodes: request.max_nodes.unwrap_or(5000),
679            include_calibration,
680            include_checkpoints,
681        })
682        .await
683        .map_err(internal_error)?;
684
685    Ok(Json(to_evict_dto(result)))
686}
687
688fn resolve_evict_mode(
689    request: &EvictHttpRequest,
690) -> Result<MemoryEvictMode, (StatusCode, Json<ErrorResponse>)> {
691    if request.purge_session.unwrap_or(false) {
692        return Ok(MemoryEvictMode::PurgeSession);
693    }
694
695    if let Some(mode) = request.mode.as_deref() {
696        return match mode.trim().to_ascii_lowercase().as_str() {
697            "by_sync_keys" | "sync_keys" => Ok(MemoryEvictMode::BySyncKeys),
698            "by_node_ids" | "node_ids" => Ok(MemoryEvictMode::ByNodeIds),
699            "by_filter" | "filter" => Ok(MemoryEvictMode::ByFilter),
700            "purge_session" | "purge" => Ok(MemoryEvictMode::PurgeSession),
701            _ => Err(bad_request(format!("unsupported evict mode: {mode}"))),
702        };
703    }
704
705    if request
706        .sync_keys
707        .as_ref()
708        .is_some_and(|keys| !keys.is_empty())
709    {
710        return Ok(MemoryEvictMode::BySyncKeys);
711    }
712    if request.node_ids.as_ref().is_some_and(|ids| !ids.is_empty()) {
713        return Ok(MemoryEvictMode::ByNodeIds);
714    }
715    if request.semantic_tags.is_some()
716        || request.tags_contains.is_some()
717        || request.link_rel.is_some()
718        || request.link_target.is_some()
719        || request.links_to_ref.is_some()
720        || request.tag_prefix.is_some()
721        || request.has_semantic_links.is_some()
722    {
723        return Ok(MemoryEvictMode::ByFilter);
724    }
725
726    Err(bad_request(
727        "evict mode could not be inferred; provide syncKeys, nodeIds, filter fields, or purgeSession=true",
728    ))
729}
730
731fn to_evict_dto(result: MemoryEvictResult) -> EvictHttpResultDto {
732    EvictHttpResultDto {
733        dry_run: result.dry_run,
734        deleted: result.deleted,
735        blocked: result.blocked,
736        not_found: result.not_found,
737        skipped: result.skipped,
738        would_delete: result.would_delete,
739        calibrations_deleted: result.calibrations_deleted,
740        checkpoints_deleted: result.checkpoints_deleted,
741        records: result
742            .records
743            .into_iter()
744            .map(|record| {
745                json!({
746                    "nodeId": record.node_id,
747                    "syncKey": record.sync_key,
748                    "status": record.status,
749                    "reason": record.reason,
750                    "inboundReferences": record.inbound_references,
751                })
752            })
753            .collect(),
754    }
755}
756
757async fn get_moods_handler(
758    State(state): State<Arc<AppState>>,
759    Query(query): Query<GetMoodsQuery>,
760) -> ApiResult<MoodCatalogResultDto> {
761    let result = state.mood_catalog.get(
762        query.target_mood.as_deref(),
763        query.blend.unwrap_or(1.0),
764        query.current_stability,
765        query.current_friction,
766        query.current_logic,
767        query.current_autonomy,
768    );
769
770    Ok(Json(to_mood_catalog_dto(result)))
771}
772
773async fn create_monthly_rollup_handler(
774    State(state): State<Arc<AppState>>,
775    headers: HeaderMap,
776    Json(request): Json<CreateMonthlyRollupHttpRequest>,
777) -> ApiResult<MonthlyRollupResultDto> {
778    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
779
780    let rollup_request = MonthlyRollupRequest {
781        session_id: scope_session_id(&tenant, &request.session_id),
782        start_utc: request.start_date_utc,
783        end_utc: request.end_date_utc,
784        source_session_id: request
785            .source_session_id
786            .map(|session_id| scope_session_id(&tenant, &session_id)),
787        parent_node_id: request.parent_node_id,
788        persist: request.persist.unwrap_or(true),
789        limit: request.limit.unwrap_or(5000),
790    };
791
792    let result = state.monthly_rollup.create_async(rollup_request).await;
793    Ok(Json(to_monthly_rollup_dto(result)))
794}
795
796async fn batch_rekey_handler(
797    State(state): State<Arc<AppState>>,
798    headers: HeaderMap,
799    Json(request): Json<BatchRekeyHttpRequest>,
800) -> ApiResult<BatchRekeyResultDto> {
801    if request.node_ids.is_empty() {
802        return Err(bad_request("nodeIds must contain at least one value"));
803    }
804
805    if request.target_session_id.trim().is_empty() {
806        return Err(bad_request("targetSessionId cannot be empty"));
807    }
808
809    let target_tenant = resolve_http_tenant(request.target_tenant_id.as_deref(), &headers);
810    let scoped_target_session = scope_session_id(&target_tenant, request.target_session_id.trim());
811
812    let result = state
813        .rekey_scope
814        .rekey_async(
815            request.node_ids,
816            &target_tenant,
817            &scoped_target_session,
818            request.dry_run.unwrap_or(true),
819            request.allow_merge.unwrap_or(false),
820        )
821        .await
822        .map_err(internal_error)?;
823
824    Ok(Json(to_batch_rekey_dto(result)))
825}
826
827async fn preview_embedding_migration_handler(
828    State(state): State<Arc<AppState>>,
829    headers: HeaderMap,
830    Json(request): Json<EmbeddingMigrationPreviewHttpRequest>,
831) -> ApiResult<EmbeddingMigrationPreviewResultDto> {
832    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
833    let sample_limit = request.sample_limit.unwrap_or(20).clamp(1, 200);
834    let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
835    let (scope, filter, sync_keys) = scoped_memory_filter(request.filter, &tenant);
836
837    let find_service = find_service(&state);
838    let find_result = find_service
839        .execute(&MemoryFindRequest {
840            scope,
841            filter,
842            page: MemoryPage {
843                limit: max_nodes,
844                cursor: None,
845            },
846            ..Default::default()
847        })
848        .await
849        .map_err(internal_error)?;
850
851    let mut nodes = find_result.nodes;
852    if let Some(sync_keys) = sync_keys {
853        nodes.retain(|node| sync_keys.iter().any(|key| key == &node.sync_key));
854    }
855
856    let total_candidates = nodes.len();
857    let sample = nodes
858        .into_iter()
859        .take(sample_limit)
860        .map(|sample| EmbeddingMigrationSampleDto {
861            sync_key: sample.sync_key,
862            session_id: display_session_id(&sample.session_id),
863            tier: sample.tier,
864            has_embedding: sample
865                .embedding
866                .as_ref()
867                .is_some_and(|values| !values.is_empty()),
868            embedding_model: sample.embedding_model,
869            embedding_dimensions: sample.embedding_dimensions,
870            embedded_at: sample.embedded_at,
871            updated_at: sample.updated_at,
872            context_summary: sample.context_summary,
873        })
874        .collect::<Vec<_>>();
875
876    Ok(Json(EmbeddingMigrationPreviewResultDto {
877        total_candidates,
878        sample,
879        provider_available: state.embedding_provider.is_some(),
880        provider_model: state
881            .embedding_provider
882            .as_ref()
883            .map(|provider| provider.model_name().to_string()),
884    }))
885}
886
887async fn run_embedding_migration_handler(
888    State(state): State<Arc<AppState>>,
889    headers: HeaderMap,
890    Json(request): Json<EmbeddingMigrationRunHttpRequest>,
891) -> ApiResult<EmbeddingMigrationRunResultDto> {
892    let tenant = resolve_http_tenant(request.tenant_id.as_deref(), &headers);
893    let mode = match request
894        .mode
895        .unwrap_or(EmbeddingMigrationModeHttp::MissingOnly)
896    {
897        EmbeddingMigrationModeHttp::MissingOnly => MemoryTransformOperation::EmbedBackfill,
898        EmbeddingMigrationModeHttp::ReindexAll => MemoryTransformOperation::ReindexEmbeddings,
899        EmbeddingMigrationModeHttp::Tags => MemoryTransformOperation::EmbedTagBackfill,
900        EmbeddingMigrationModeHttp::Both => MemoryTransformOperation::EmbedBackfill,
901    };
902    let dry_run = request.dry_run.unwrap_or(true);
903    let batch_size = request.batch_size.unwrap_or(100).clamp(1, 500);
904    let max_nodes = request.max_nodes.unwrap_or(5_000).clamp(1, 50_000);
905    let (scope, filter, _sync_keys) = scoped_memory_filter(request.filter.clone(), &tenant);
906
907    let transform_service = transform_service(&state);
908
909    let mut result = transform_service
910        .execute(&MemoryTransformRequest {
911            scope: scope.clone(),
912            filter: filter.clone(),
913            operation: mode,
914            dry_run,
915            batch_size,
916            max_nodes,
917            provider_id: state
918                .embedding_provider
919                .as_ref()
920                .map(|_| "gateway-embedding".to_string()),
921            model: state
922                .embedding_provider
923                .as_ref()
924                .map(|provider| provider.model_name().to_string()),
925        })
926        .await
927        .map_err(internal_error)?;
928
929    if request.mode == Some(EmbeddingMigrationModeHttp::Both) && !dry_run {
930        let tag_result = transform_service
931            .execute(&MemoryTransformRequest {
932                scope,
933                filter,
934                operation: MemoryTransformOperation::EmbedTagBackfill,
935                dry_run: false,
936                batch_size,
937                max_nodes,
938                provider_id: state
939                    .embedding_provider
940                    .as_ref()
941                    .map(|_| "gateway-embedding".to_string()),
942                model: state
943                    .embedding_provider
944                    .as_ref()
945                    .map(|provider| provider.model_name().to_string()),
946            })
947            .await
948            .map_err(internal_error)?;
949        result.updated += tag_result.updated;
950        result.failed += tag_result.failed;
951        result.skipped += tag_result.skipped;
952        result.failures.extend(tag_result.failures);
953    }
954
955    Ok(Json(to_embedding_migration_run_dto(
956        result,
957        mode,
958        dry_run,
959        state
960            .embedding_provider
961            .as_ref()
962            .map(|provider| provider.model_name().to_string()),
963    )))
964}
965
966fn recall_service(state: &AppState) -> MemoryRecallService {
967    MemoryRecallService::new(state.node_store.clone())
968        .with_semantic_index(state.semantic_index.clone())
969}
970
971fn find_service(state: &AppState) -> MemoryFindService {
972    MemoryFindService::new(state.node_store.clone())
973        .with_semantic_index(state.semantic_index.clone())
974}
975
976fn evict_service(state: &AppState) -> MemoryEvictService {
977    MemoryEvictService::new(state.node_store.clone())
978        .with_semantic_index(state.semantic_index.clone())
979}
980
981fn graph_service(state: &AppState) -> MemoryGraphService {
982    MemoryGraphService::new(state.node_store.clone())
983        .with_semantic_index(state.semantic_index.clone())
984}
985
986fn transform_service(state: &AppState) -> MemoryTransformService {
987    let providers = build_gateway_provider_registry(state.embedding_provider.clone());
988    MemoryTransformService::new(state.node_store.clone(), providers)
989        .with_semantic_index(state.semantic_index.clone())
990}
991
992fn build_semantic_memory_filter(
993    semantic_tags: Option<Vec<String>>,
994    tags_contains: Option<Vec<String>>,
995    link_rel: Option<String>,
996    link_target: Option<String>,
997    links_to_ref: Option<String>,
998    tag_prefix: Option<String>,
999    has_semantic_links: Option<bool>,
1000) -> MemoryFilter {
1001    MemoryFilter {
1002        indexed_tags: semantic_tags,
1003        tags_contains,
1004        link_rel,
1005        link_target,
1006        links_to_ref,
1007        tag_prefix,
1008        has_semantic_links,
1009        ..Default::default()
1010    }
1011}
1012
1013fn bad_request(message: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
1014    (
1015        StatusCode::BAD_REQUEST,
1016        Json(ErrorResponse {
1017            error: message.into(),
1018        }),
1019    )
1020}
1021
1022fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
1023    (
1024        StatusCode::INTERNAL_SERVER_ERROR,
1025        Json(ErrorResponse {
1026            error: error.to_string(),
1027        }),
1028    )
1029}
1030
1031fn to_avec_dto(value: core_models::AvecState) -> AvecStateDto {
1032    AvecStateDto {
1033        stability: value.stability,
1034        friction: value.friction,
1035        logic: value.logic,
1036        autonomy: value.autonomy,
1037        psi: value.psi(),
1038    }
1039}
1040
1041fn to_node_dto(value: &core_models::SttpNode) -> SttpNodeDto {
1042    SttpNodeDto {
1043        raw: value.raw.clone(),
1044        session_id: display_session_id(&value.session_id),
1045        tier: value.tier.clone(),
1046        timestamp: value.timestamp,
1047        compression_depth: value.compression_depth,
1048        parent_node_id: value.parent_node_id.clone(),
1049        semantic_tags: value.semantic_tags.clone(),
1050        semantic_links: value.semantic_links.clone(),
1051        user_avec: to_avec_dto(value.user_avec),
1052        model_avec: to_avec_dto(value.model_avec),
1053        compression_avec: value.compression_avec.map(to_avec_dto),
1054        rho: value.rho,
1055        kappa: value.kappa,
1056        psi: value.psi,
1057        sync_key: value.sync_key.clone(),
1058        synthetic_id: graph_node_id(value),
1059    }
1060}
1061
1062fn to_mood_catalog_dto(result: core_models::MoodCatalogResult) -> MoodCatalogResultDto {
1063    MoodCatalogResultDto {
1064        presets: result
1065            .presets
1066            .into_iter()
1067            .map(|preset| MoodPresetDto {
1068                name: preset.name,
1069                description: preset.description,
1070                avec: to_avec_dto(preset.avec),
1071            })
1072            .collect(),
1073        apply_guide: result.apply_guide,
1074        swap_preview: result.swap_preview.map(|preview| MoodSwapPreviewDto {
1075            target_mood: preview.target_mood,
1076            blend: preview.blend,
1077            current: to_avec_dto(preview.current),
1078            target: to_avec_dto(preview.target),
1079            blended: to_avec_dto(preview.blended),
1080        }),
1081    }
1082}
1083
1084fn to_monthly_rollup_dto(result: core_models::MonthlyRollupResult) -> MonthlyRollupResultDto {
1085    MonthlyRollupResultDto {
1086        success: result.success,
1087        node_id: result.node_id,
1088        raw_node: result.raw_node,
1089        error: result.error,
1090        source_nodes: result.source_nodes,
1091        parent_reference: result.parent_reference,
1092        user_average: to_avec_dto(result.user_average),
1093        model_average: to_avec_dto(result.model_average),
1094        compression_average: to_avec_dto(result.compression_average),
1095        rho_range: to_numeric_range_dto(result.rho_range),
1096        kappa_range: to_numeric_range_dto(result.kappa_range),
1097        psi_range: to_numeric_range_dto(result.psi_range),
1098        rho_bands: to_confidence_bands_dto(result.rho_bands),
1099        kappa_bands: to_confidence_bands_dto(result.kappa_bands),
1100    }
1101}
1102
1103fn to_batch_rekey_dto(result: core_models::BatchRekeyResult) -> BatchRekeyResultDto {
1104    let updated_scopes = result.scopes.iter().filter(|scope| scope.applied).count();
1105    let conflict_scopes = result.scopes.iter().filter(|scope| scope.conflict).count();
1106
1107    BatchRekeyResultDto {
1108        dry_run: result.dry_run,
1109        requested_node_ids: result.requested_node_ids,
1110        resolved_node_ids: result.resolved_node_ids,
1111        missing_node_ids: result.missing_node_ids,
1112        scopes: result
1113            .scopes
1114            .into_iter()
1115            .map(|scope| ScopeRekeyResultDto {
1116                source_tenant_id: scope.source_tenant_id,
1117                source_session_id: display_session_id(&scope.source_session_id),
1118                target_tenant_id: scope.target_tenant_id,
1119                target_session_id: display_session_id(&scope.target_session_id),
1120                temporal_nodes: scope.temporal_nodes,
1121                calibrations: scope.calibrations,
1122                target_temporal_nodes: scope.target_temporal_nodes,
1123                target_calibrations: scope.target_calibrations,
1124                applied: scope.applied,
1125                conflict: scope.conflict,
1126                message: scope.message,
1127            })
1128            .collect(),
1129        temporal_nodes_updated: result.temporal_nodes_updated,
1130        calibrations_updated: result.calibrations_updated,
1131        updated_scopes,
1132        conflict_scopes,
1133    }
1134}
1135
1136fn scoped_memory_filter(
1137    request_filter: Option<EmbeddingMigrationFilterHttp>,
1138    tenant: &str,
1139) -> (MemoryScope, MemoryFilter, Option<Vec<String>>) {
1140    let filter = request_filter.unwrap_or(EmbeddingMigrationFilterHttp {
1141        session_id: None,
1142        from_utc: None,
1143        to_utc: None,
1144        tiers: None,
1145        has_embedding: None,
1146        embedding_model: None,
1147        sync_keys: None,
1148    });
1149
1150    (
1151        MemoryScope {
1152            tenant_id: None,
1153            session_ids: filter
1154                .session_id
1155                .map(|session_id| vec![scope_session_id(tenant, &session_id)]),
1156            tiers: filter.tiers,
1157            from_utc: filter.from_utc,
1158            to_utc: filter.to_utc,
1159        },
1160        MemoryFilter {
1161            has_embedding: filter.has_embedding,
1162            embedding_model: filter.embedding_model,
1163            ..Default::default()
1164        },
1165        filter.sync_keys,
1166    )
1167}
1168
1169fn build_gateway_provider_registry(
1170    provider: Option<Arc<dyn EmbeddingProvider>>,
1171) -> Arc<InMemoryAiProviderRegistry> {
1172    let mut registry = InMemoryAiProviderRegistry::new();
1173    if let Some(provider) = provider {
1174        registry.register(SttpEmbeddingProviderAdapter::new(
1175            "gateway-embedding",
1176            provider,
1177        ));
1178    }
1179    Arc::new(registry)
1180}
1181
1182fn to_embedding_migration_run_dto(
1183    result: locus_sdk::domain::memory::MemoryTransformResult,
1184    mode: MemoryTransformOperation,
1185    dry_run: bool,
1186    provider_model: Option<String>,
1187) -> EmbeddingMigrationRunResultDto {
1188    EmbeddingMigrationRunResultDto {
1189        scanned: result.scanned,
1190        selected: result.selected,
1191        updated: result.updated,
1192        skipped: result.skipped,
1193        failed: result.failed,
1194        duplicate: result.duplicate,
1195        started_at: result.started_at,
1196        completed_at: result.completed_at,
1197        provider_model,
1198        dry_run,
1199        mode: match mode {
1200            MemoryTransformOperation::EmbedBackfill => "missing_only".to_string(),
1201            MemoryTransformOperation::ReindexEmbeddings => "reindex_all".to_string(),
1202            MemoryTransformOperation::EmbedTagBackfill => "tags".to_string(),
1203            MemoryTransformOperation::ReindexTagEmbeddings => "reindex_tags".to_string(),
1204        },
1205        failure_reasons: result.failures,
1206    }
1207}
1208
1209fn to_numeric_range_dto(value: NumericRange) -> NumericRangeDto {
1210    NumericRangeDto {
1211        min: value.min,
1212        max: value.max,
1213        average: value.average,
1214    }
1215}
1216
1217fn to_confidence_bands_dto(value: ConfidenceBandSummary) -> ConfidenceBandSummaryDto {
1218    ConfidenceBandSummaryDto {
1219        low: value.low,
1220        medium: value.medium,
1221        high: value.high,
1222    }
1223}
1224
1225#[derive(Clone)]
1226struct GrpcGatewayService {
1227    state: Arc<AppState>,
1228}
1229
1230impl GrpcGatewayService {
1231    fn new(state: Arc<AppState>) -> Self {
1232        Self { state }
1233    }
1234}
1235
1236#[tonic::async_trait]
1237impl proto::sttp_gateway_service_server::SttpGatewayService for GrpcGatewayService {
1238    async fn calibrate_session(
1239        &self,
1240        request: Request<proto::CalibrateSessionRequest>,
1241    ) -> Result<Response<proto::CalibrateSessionReply>, Status> {
1242        let tenant = resolve_grpc_tenant(request.metadata());
1243        let request = request.into_inner();
1244        let trigger = if request.trigger.trim().is_empty() {
1245            "manual"
1246        } else {
1247            &request.trigger
1248        };
1249        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1250
1251        let result = self
1252            .state
1253            .calibration
1254            .calibrate_async(
1255                &scoped_session_id,
1256                request.stability,
1257                request.friction,
1258                request.logic,
1259                request.autonomy,
1260                trigger,
1261            )
1262            .await
1263            .map_err(|err| Status::internal(err.to_string()))?;
1264
1265        let reply = proto::CalibrateSessionReply {
1266            previous_avec: Some(to_grpc_avec(result.previous_avec)),
1267            delta: result.delta,
1268            drift_classification: format!("{:?}", result.drift_classification),
1269            trigger: result.trigger,
1270            trigger_history: result.trigger_history,
1271            is_first_calibration: result.is_first_calibration,
1272        };
1273
1274        Ok(Response::new(reply))
1275    }
1276
1277    async fn store_context(
1278        &self,
1279        request: Request<proto::StoreContextRequest>,
1280    ) -> Result<Response<proto::StoreContextReply>, Status> {
1281        let tenant = resolve_grpc_tenant(request.metadata());
1282        let request = request.into_inner();
1283        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1284
1285        let result = self
1286            .state
1287            .store_context
1288            .store_async(&request.node, &scoped_session_id)
1289            .await;
1290
1291        let reply = proto::StoreContextReply {
1292            node_id: result.node_id,
1293            psi: result.psi,
1294            valid: result.valid,
1295            validation_error: result.validation_error,
1296        };
1297
1298        Ok(Response::new(reply))
1299    }
1300
1301    async fn get_context(
1302        &self,
1303        request: Request<proto::GetContextRequest>,
1304    ) -> Result<Response<proto::GetContextReply>, Status> {
1305        let tenant = resolve_grpc_tenant(request.metadata());
1306        let request = request.into_inner();
1307        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1308
1309        let query_embedding = resolve_query_embedding(
1310            self.state.embedding_provider.as_ref(),
1311            request.query_text.as_deref(),
1312            if request.query_embedding.is_empty() {
1313                None
1314            } else {
1315                Some(request.query_embedding.as_slice())
1316            },
1317        )
1318        .await;
1319
1320        let limit = if request.limit <= 0 {
1321            5
1322        } else {
1323            request.limit as usize
1324        };
1325        let tiers = normalize_request_tiers(Some(&request.tiers));
1326        let recall_service = MemoryRecallService::new(self.state.node_store.clone());
1327        let recall_result = recall_service
1328            .execute(&MemoryRecallRequest {
1329                scope: MemoryScope {
1330                    tenant_id: None,
1331                    session_ids: Some(vec![scoped_session_id]),
1332                    tiers,
1333                    from_utc: timestamp_from_proto_optional(request.from_utc)?,
1334                    to_utc: timestamp_from_proto_optional(request.to_utc)?,
1335                },
1336                page: MemoryPage {
1337                    limit,
1338                    cursor: None,
1339                },
1340                scoring: MemoryScoring {
1341                    alpha: request
1342                        .alpha
1343                        .unwrap_or(DEFAULT_HYBRID_ALPHA)
1344                        .clamp(0.0, 1.0),
1345                    beta: request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1346                    fallback_policy: FallbackPolicy::OnEmpty,
1347                    ..Default::default()
1348                },
1349                current_avec: Some(core_models::AvecState {
1350                    stability: request.stability,
1351                    friction: request.friction,
1352                    logic: request.logic,
1353                    autonomy: request.autonomy,
1354                }),
1355                query_text: request.query_text,
1356                query_embedding,
1357                ..Default::default()
1358            })
1359            .await
1360            .map_err(|err| Status::internal(err.to_string()))?;
1361
1362        let nodes = recall_result
1363            .nodes
1364            .iter()
1365            .cloned()
1366            .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1367            .collect::<Vec<_>>();
1368
1369        let reply = proto::GetContextReply {
1370            nodes: nodes.iter().map(to_grpc_node).collect(),
1371            retrieved: clamp_usize_to_i32(nodes.len()),
1372            psi_range: Some(to_grpc_psi_range(recall_result.psi_range)),
1373        };
1374
1375        Ok(Response::new(reply))
1376    }
1377
1378    async fn get_embedding_context(
1379        &self,
1380        request: Request<proto::GetEmbeddingContextRequest>,
1381    ) -> Result<Response<proto::GetContextReply>, Status> {
1382        let tenant = resolve_grpc_tenant(request.metadata());
1383        let request = request.into_inner();
1384        let scoped_session_id = scope_session_id(&tenant, &request.session_id);
1385
1386        let rag_embedding = resolve_query_embedding(
1387            self.state.embedding_provider.as_ref(),
1388            request.rag_query_text.as_deref(),
1389            if request.rag_embedding.is_empty() {
1390                None
1391            } else {
1392                Some(request.rag_embedding.as_slice())
1393            },
1394        )
1395        .await;
1396
1397        let avec_embedding = resolve_query_embedding(
1398            self.state.embedding_provider.as_ref(),
1399            request.avec_query_text.as_deref(),
1400            if request.avec_embedding.is_empty() {
1401                None
1402            } else {
1403                Some(request.avec_embedding.as_slice())
1404            },
1405        )
1406        .await;
1407
1408        let fused_embedding = fuse_weighted_embeddings(
1409            rag_embedding.as_deref(),
1410            avec_embedding.as_deref(),
1411            request.rag_weight.unwrap_or(0.7),
1412            request.avec_weight.unwrap_or(0.3),
1413        )
1414        .map_err(|(_, payload)| Status::invalid_argument(payload.0.error))?;
1415
1416        if fused_embedding.is_empty() {
1417            return Err(Status::invalid_argument(
1418                "Provide rag_embedding/rag_query_text and/or avec_embedding/avec_query_text",
1419            ));
1420        }
1421
1422        let limit = if request.limit <= 0 {
1423            5
1424        } else {
1425            request.limit as usize
1426        };
1427        let tiers = normalize_request_tiers(Some(&request.tiers));
1428
1429        let result = self
1430            .state
1431            .context_query
1432            .get_context_hybrid_scoped_filtered_async(
1433                Some(&scoped_session_id),
1434                request.stability,
1435                request.friction,
1436                request.logic,
1437                request.autonomy,
1438                timestamp_from_proto_optional(request.from_utc)?,
1439                timestamp_from_proto_optional(request.to_utc)?,
1440                tiers.as_deref(),
1441                Some(fused_embedding.as_slice()),
1442                request
1443                    .alpha
1444                    .unwrap_or(DEFAULT_HYBRID_ALPHA)
1445                    .clamp(0.0, 1.0),
1446                request.beta.unwrap_or(DEFAULT_HYBRID_BETA).clamp(0.0, 1.0),
1447                limit,
1448            )
1449            .await;
1450
1451        let nodes = result
1452            .nodes
1453            .iter()
1454            .cloned()
1455            .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1456            .collect::<Vec<_>>();
1457
1458        let reply = proto::GetContextReply {
1459            nodes: nodes.iter().map(to_grpc_node).collect(),
1460            retrieved: clamp_usize_to_i32(nodes.len()),
1461            psi_range: Some(to_grpc_psi_range(result.psi_range)),
1462        };
1463
1464        Ok(Response::new(reply))
1465    }
1466
1467    async fn list_nodes(
1468        &self,
1469        request: Request<proto::ListNodesRequest>,
1470    ) -> Result<Response<proto::ListNodesReply>, Status> {
1471        let tenant = resolve_grpc_tenant(request.metadata());
1472        let request = request.into_inner();
1473        let requested_limit = if request.limit <= 0 {
1474            50
1475        } else {
1476            request.limit as usize
1477        }
1478        .clamp(1, TENANT_SCAN_LIMIT);
1479        let scoped_session_filter = request
1480            .session_id
1481            .as_deref()
1482            .map(|session_id| scope_session_id(&tenant, session_id));
1483        let backend_limit = if scoped_session_filter.is_some() {
1484            requested_limit
1485        } else {
1486            TENANT_SCAN_LIMIT
1487        };
1488
1489        let find_service = MemoryFindService::new(self.state.node_store.clone());
1490        let result = find_service
1491            .execute(&MemoryFindRequest {
1492                scope: MemoryScope {
1493                    tenant_id: None,
1494                    session_ids: scoped_session_filter.map(|session| vec![session]),
1495                    tiers: None,
1496                    from_utc: None,
1497                    to_utc: None,
1498                },
1499                page: MemoryPage {
1500                    limit: backend_limit,
1501                    cursor: None,
1502                },
1503                ..Default::default()
1504            })
1505            .await
1506            .map_err(|err| Status::internal(err.to_string()))?;
1507
1508        let nodes = result
1509            .nodes
1510            .into_iter()
1511            .filter_map(|node| normalize_node_for_tenant(node, &tenant))
1512            .take(requested_limit)
1513            .collect::<Vec<_>>();
1514
1515        let reply = proto::ListNodesReply {
1516            nodes: nodes.iter().map(to_grpc_node).collect(),
1517            retrieved: clamp_usize_to_i32(nodes.len()),
1518        };
1519
1520        Ok(Response::new(reply))
1521    }
1522
1523    async fn get_moods(
1524        &self,
1525        request: Request<proto::GetMoodsRequest>,
1526    ) -> Result<Response<proto::GetMoodsReply>, Status> {
1527        let request = request.into_inner();
1528        let result = self.state.mood_catalog.get(
1529            request.target_mood.as_deref(),
1530            request.blend,
1531            request.current_stability,
1532            request.current_friction,
1533            request.current_logic,
1534            request.current_autonomy,
1535        );
1536
1537        let reply = proto::GetMoodsReply {
1538            presets: result
1539                .presets
1540                .into_iter()
1541                .map(|preset| proto::MoodPreset {
1542                    name: preset.name,
1543                    description: preset.description,
1544                    avec: Some(to_grpc_avec(preset.avec)),
1545                })
1546                .collect(),
1547            apply_guide: result.apply_guide,
1548            swap_preview: result.swap_preview.map(|preview| proto::MoodSwapPreview {
1549                target_mood: preview.target_mood,
1550                blend: preview.blend,
1551                current: Some(to_grpc_avec(preview.current)),
1552                target: Some(to_grpc_avec(preview.target)),
1553                blended: Some(to_grpc_avec(preview.blended)),
1554            }),
1555        };
1556
1557        Ok(Response::new(reply))
1558    }
1559
1560    async fn batch_rekey(
1561        &self,
1562        request: Request<proto::BatchRekeyRequest>,
1563    ) -> Result<Response<proto::BatchRekeyReply>, Status> {
1564        let metadata_tenant = resolve_grpc_tenant(request.metadata());
1565        let request = request.into_inner();
1566
1567        if request.node_ids.is_empty() {
1568            return Err(Status::invalid_argument(
1569                "node_ids must contain at least one value",
1570            ));
1571        }
1572
1573        if request.target_session_id.trim().is_empty() {
1574            return Err(Status::invalid_argument(
1575                "target_session_id cannot be empty",
1576            ));
1577        }
1578
1579        let target_tenant = request
1580            .target_tenant_id
1581            .as_deref()
1582            .and_then(normalize_tenant_value)
1583            .unwrap_or(metadata_tenant);
1584        let scoped_target_session =
1585            scope_session_id(&target_tenant, request.target_session_id.trim());
1586
1587        let result = self
1588            .state
1589            .rekey_scope
1590            .rekey_async(
1591                request.node_ids,
1592                &target_tenant,
1593                &scoped_target_session,
1594                request.dry_run.unwrap_or(true),
1595                request.allow_merge.unwrap_or(false),
1596            )
1597            .await
1598            .map_err(|err| Status::internal(err.to_string()))?;
1599
1600        Ok(Response::new(to_grpc_batch_rekey_reply(result)))
1601    }
1602
1603    async fn create_monthly_rollup(
1604        &self,
1605        request: Request<proto::CreateMonthlyRollupRequest>,
1606    ) -> Result<Response<proto::CreateMonthlyRollupReply>, Status> {
1607        let tenant = resolve_grpc_tenant(request.metadata());
1608        let request = request.into_inner();
1609
1610        let start_utc = timestamp_from_proto(request.start_utc)?;
1611        let end_utc = timestamp_from_proto(request.end_utc)?;
1612
1613        let monthly_request = MonthlyRollupRequest {
1614            session_id: scope_session_id(&tenant, &request.session_id),
1615            start_utc,
1616            end_utc,
1617            source_session_id: request
1618                .source_session_id
1619                .map(|session_id| scope_session_id(&tenant, &session_id)),
1620            parent_node_id: request.parent_node_id,
1621            persist: request.persist,
1622            limit: if request.limit <= 0 {
1623                5000
1624            } else {
1625                request.limit as usize
1626            },
1627        };
1628
1629        let result = self
1630            .state
1631            .monthly_rollup
1632            .create_async(monthly_request)
1633            .await;
1634
1635        let reply = proto::CreateMonthlyRollupReply {
1636            success: result.success,
1637            node_id: result.node_id,
1638            raw_node: result.raw_node,
1639            error: result.error,
1640            source_nodes: clamp_usize_to_i32(result.source_nodes),
1641            parent_reference: result.parent_reference,
1642            user_average: Some(to_grpc_avec(result.user_average)),
1643            model_average: Some(to_grpc_avec(result.model_average)),
1644            compression_average: Some(to_grpc_avec(result.compression_average)),
1645            rho_range: Some(to_grpc_numeric_range(result.rho_range)),
1646            kappa_range: Some(to_grpc_numeric_range(result.kappa_range)),
1647            psi_range: Some(to_grpc_numeric_range(result.psi_range)),
1648            rho_bands: Some(to_grpc_confidence_bands(result.rho_bands)),
1649            kappa_bands: Some(to_grpc_confidence_bands(result.kappa_bands)),
1650        };
1651
1652        Ok(Response::new(reply))
1653    }
1654}
1655
1656fn to_grpc_avec(value: core_models::AvecState) -> proto::AvecState {
1657    proto::AvecState {
1658        stability: value.stability,
1659        friction: value.friction,
1660        logic: value.logic,
1661        autonomy: value.autonomy,
1662        psi: value.psi(),
1663    }
1664}
1665
1666fn to_grpc_node(value: &core_models::SttpNode) -> proto::SttpNode {
1667    proto::SttpNode {
1668        raw: value.raw.clone(),
1669        session_id: display_session_id(&value.session_id),
1670        tier: value.tier.clone(),
1671        timestamp: Some(timestamp_to_proto(value.timestamp)),
1672        compression_depth: value.compression_depth,
1673        parent_node_id: value.parent_node_id.clone(),
1674        user_avec: Some(to_grpc_avec(value.user_avec)),
1675        model_avec: Some(to_grpc_avec(value.model_avec)),
1676        compression_avec: value.compression_avec.map(to_grpc_avec),
1677        rho: value.rho,
1678        kappa: value.kappa,
1679        psi: value.psi,
1680        semantic_tags: value.semantic_tags.clone().unwrap_or_default(),
1681        semantic_links: value
1682            .semantic_links
1683            .clone()
1684            .unwrap_or_default()
1685            .into_iter()
1686            .map(|link| proto::SemanticLink {
1687                rel: link.rel,
1688                target: link.target,
1689                confidence: link.confidence,
1690            })
1691            .collect(),
1692    }
1693}
1694
1695fn to_grpc_psi_range(value: PsiRange) -> proto::PsiRange {
1696    proto::PsiRange {
1697        min: value.min,
1698        max: value.max,
1699        average: value.average,
1700    }
1701}
1702
1703fn to_grpc_numeric_range(value: NumericRange) -> proto::NumericRange {
1704    proto::NumericRange {
1705        min: value.min,
1706        max: value.max,
1707        average: value.average,
1708    }
1709}
1710
1711fn to_grpc_confidence_bands(value: ConfidenceBandSummary) -> proto::ConfidenceBandSummary {
1712    proto::ConfidenceBandSummary {
1713        low: clamp_usize_to_i32(value.low),
1714        medium: clamp_usize_to_i32(value.medium),
1715        high: clamp_usize_to_i32(value.high),
1716    }
1717}
1718
1719fn to_grpc_scope_rekey_result(value: core_models::ScopeRekeyResult) -> proto::ScopeRekeyResult {
1720    proto::ScopeRekeyResult {
1721        source_tenant_id: value.source_tenant_id,
1722        source_session_id: display_session_id(&value.source_session_id),
1723        target_tenant_id: value.target_tenant_id,
1724        target_session_id: display_session_id(&value.target_session_id),
1725        temporal_nodes: clamp_usize_to_i32(value.temporal_nodes),
1726        calibrations: clamp_usize_to_i32(value.calibrations),
1727        target_temporal_nodes: clamp_usize_to_i32(value.target_temporal_nodes),
1728        target_calibrations: clamp_usize_to_i32(value.target_calibrations),
1729        applied: value.applied,
1730        conflict: value.conflict,
1731        message: value.message,
1732    }
1733}
1734
1735fn to_grpc_batch_rekey_reply(value: core_models::BatchRekeyResult) -> proto::BatchRekeyReply {
1736    let updated_scopes = value.scopes.iter().filter(|scope| scope.applied).count();
1737    let conflict_scopes = value.scopes.iter().filter(|scope| scope.conflict).count();
1738
1739    proto::BatchRekeyReply {
1740        dry_run: value.dry_run,
1741        requested_node_ids: clamp_usize_to_i32(value.requested_node_ids),
1742        resolved_node_ids: clamp_usize_to_i32(value.resolved_node_ids),
1743        missing_node_ids: value.missing_node_ids,
1744        scopes: value
1745            .scopes
1746            .into_iter()
1747            .map(to_grpc_scope_rekey_result)
1748            .collect(),
1749        temporal_nodes_updated: clamp_usize_to_i32(value.temporal_nodes_updated),
1750        calibrations_updated: clamp_usize_to_i32(value.calibrations_updated),
1751        updated_scopes: clamp_usize_to_i32(updated_scopes),
1752        conflict_scopes: clamp_usize_to_i32(conflict_scopes),
1753    }
1754}
1755
1756fn timestamp_to_proto(value: DateTime<Utc>) -> prost_types::Timestamp {
1757    prost_types::Timestamp {
1758        seconds: value.timestamp(),
1759        nanos: value.timestamp_subsec_nanos() as i32,
1760    }
1761}
1762
1763fn timestamp_from_proto(value: Option<prost_types::Timestamp>) -> Result<DateTime<Utc>, Status> {
1764    let value = value.ok_or_else(|| Status::invalid_argument("missing timestamp"))?;
1765    DateTime::<Utc>::from_timestamp(value.seconds, value.nanos as u32)
1766        .ok_or_else(|| Status::invalid_argument("invalid timestamp"))
1767}
1768
1769fn timestamp_from_proto_optional(
1770    value: Option<prost_types::Timestamp>,
1771) -> Result<Option<DateTime<Utc>>, Status> {
1772    value
1773        .map(|timestamp| timestamp_from_proto(Some(timestamp)))
1774        .transpose()
1775}
1776
1777fn normalize_request_tiers(tiers: Option<&[String]>) -> Option<Vec<String>> {
1778    let normalized = tiers
1779        .unwrap_or(&[])
1780        .iter()
1781        .map(|tier| tier.trim().to_ascii_lowercase())
1782        .filter(|tier| !tier.is_empty())
1783        .collect::<Vec<_>>();
1784
1785    if normalized.is_empty() {
1786        None
1787    } else {
1788        Some(normalized)
1789    }
1790}
1791
1792fn clamp_usize_to_i32(value: usize) -> i32 {
1793    if value > i32::MAX as usize {
1794        i32::MAX
1795    } else {
1796        value as i32
1797    }
1798}
1799
1800#[cfg(test)]
1801mod tests {
1802    use super::*;
1803    use crate::gateway::proto::sttp_gateway_service_server::SttpGatewayService;
1804    use crate::gateway_args::{EmbeddingsProviderKind, GatewayBackend};
1805    use crate::providers::parse_avec_state_from_text;
1806    use crate::tenant::session_belongs_to_tenant;
1807    use locus_core_rs::storage::{
1808        SurrealDbEndpointsSettings, SurrealDbRuntimeOptions, SurrealDbSettings,
1809    };
1810
1811    fn sample_node(session_id: &str) -> String {
1812        format!(
1813            r#"
1814⊕⟨ {{ 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 }} }} ⟩
1815⦿⟨ {{ 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 }} }} ⟩
1816◈⟨ {{ test(.99): "gateway parser check" }} ⟩
1817⍉⟨ {{ 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 }} }} ⟩
1818"#
1819        )
1820    }
1821
1822    fn surreal_test_args() -> GatewayArgs {
1823        GatewayArgs {
1824            http_port: 8080,
1825            grpc_port: 8081,
1826            backend: GatewayBackend::Surreal,
1827            root_dir_name: ".locus-gateway".to_string(),
1828            remote: true,
1829            cors_enabled: true,
1830            cors_allowed_origins: "*".to_string(),
1831            surreal_embedded_endpoint: None,
1832            surreal_remote_endpoint: Some("ws://127.0.0.1:8000/rpc".to_string()),
1833            surreal_namespace: "entasis".to_string(),
1834            surreal_database: "locus_gateway".to_string(),
1835            surreal_user: "root".to_string(),
1836            surreal_password: "root".to_string(),
1837            sqlite_path: "data/locus-gateway.sqlite".to_string(),
1838            embeddings_enabled: false,
1839            embeddings_provider: EmbeddingsProviderKind::Ollama,
1840            embeddings_endpoint: "http://127.0.0.1:11434/api/embeddings".to_string(),
1841            embeddings_model: "sttp-encoder".to_string(),
1842            embeddings_repo: "sentence-transformers/all-MiniLM-L6-v2".to_string(),
1843            avec_scoring_enabled: false,
1844            avec_scoring_endpoint: "http://127.0.0.1:11434/api/chat".to_string(),
1845            avec_scoring_model: "qwen2.5:0.5b".to_string(),
1846        }
1847    }
1848
1849    #[test]
1850    fn parse_avec_state_accepts_plain_json() {
1851        let value = parse_avec_state_from_text(
1852            r#"{"stability":0.8,"friction":0.2,"logic":0.9,"autonomy":0.7}"#,
1853        )
1854        .expect("plain JSON should parse");
1855
1856        assert!((value.stability - 0.8).abs() < f32::EPSILON);
1857        assert!((value.friction - 0.2).abs() < f32::EPSILON);
1858        assert!((value.logic - 0.9).abs() < f32::EPSILON);
1859        assert!((value.autonomy - 0.7).abs() < f32::EPSILON);
1860    }
1861
1862    #[test]
1863    fn parse_avec_state_extracts_embedded_json() {
1864        let value = parse_avec_state_from_text(
1865            "Here is the answer: {\"stability\":1.1,\"friction\":-0.1,\"logic\":0.5,\"autonomy\":0.6}",
1866        )
1867        .expect("embedded JSON should parse");
1868
1869        assert!((value.stability - 1.0).abs() < f32::EPSILON);
1870        assert!((value.friction - 0.0).abs() < f32::EPSILON);
1871        assert!((value.logic - 0.5).abs() < f32::EPSILON);
1872        assert!((value.autonomy - 0.6).abs() < f32::EPSILON);
1873    }
1874
1875    #[tokio::test]
1876    async fn score_avec_returns_bad_request_when_disabled() {
1877        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1878
1879        let err = score_avec_handler(
1880            State(state),
1881            HeaderMap::new(),
1882            Json(ScoreAvecHttpRequest {
1883                text: "hello world".to_string(),
1884                tenant_id: None,
1885            }),
1886        )
1887        .await
1888        .expect_err("scoring should fail when disabled");
1889
1890        assert_eq!(err.0, StatusCode::BAD_REQUEST);
1891    }
1892
1893    #[test]
1894    fn surreal_runtime_options_are_derived_from_gateway_args() {
1895        let args = surreal_test_args();
1896        let mut settings = SurrealDbSettings::default();
1897        settings.endpoints = SurrealDbEndpointsSettings {
1898            embedded: args.surreal_embedded_endpoint.clone(),
1899            remote: args.surreal_remote_endpoint.clone(),
1900        };
1901        settings.namespace = args.surreal_namespace.clone();
1902        settings.database = args.surreal_database.clone();
1903
1904        let runtime_args = vec!["--remote".to_string()];
1905        let runtime = SurrealDbRuntimeOptions::from_args(
1906            &runtime_args,
1907            &settings,
1908            Some(args.root_dir_name.as_str()),
1909        )
1910        .expect("runtime options should be computed");
1911
1912        assert!(runtime.use_remote);
1913        assert_eq!(runtime.endpoint, "ws://127.0.0.1:8000/rpc");
1914        assert_eq!(runtime.namespace, "entasis");
1915        assert_eq!(runtime.database, "locus_gateway");
1916    }
1917
1918    #[test]
1919    fn tenant_scoping_is_applied_only_for_non_default_tenant() {
1920        let scoped = scope_session_id("acme", "session-1");
1921        assert_eq!(scoped, "tenant:acme::session:session-1");
1922        assert!(session_belongs_to_tenant(&scoped, "acme"));
1923        assert!(!session_belongs_to_tenant(&scoped, "default"));
1924        assert_eq!(display_session_id(&scoped), "session-1");
1925
1926        let legacy = scope_session_id("default", "session-1");
1927        assert_eq!(legacy, "session-1");
1928        assert!(session_belongs_to_tenant(&legacy, "default"));
1929    }
1930
1931    #[tokio::test]
1932    async fn http_calibrate_defaults_trigger_to_manual() {
1933        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1934
1935        let request = CalibrateSessionHttpRequest {
1936            session_id: "http-calibrate-session".to_string(),
1937            tenant_id: None,
1938            stability: 0.8,
1939            friction: 0.2,
1940            logic: 0.8,
1941            autonomy: 0.7,
1942            trigger: None,
1943        };
1944
1945        let Json(reply) = calibrate_handler(State(state), HeaderMap::new(), Json(request))
1946            .await
1947            .expect("calibrate should succeed");
1948
1949        assert_eq!(reply.trigger, "manual");
1950        assert!(reply.is_first_calibration);
1951    }
1952
1953    #[tokio::test]
1954    async fn http_store_then_get_context_roundtrip() {
1955        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
1956        let session_id = "http-store-session";
1957
1958        let Json(store_reply) = store_context_handler(
1959            State(state.clone()),
1960            HeaderMap::new(),
1961            Json(StoreContextHttpRequest {
1962                node: sample_node(session_id),
1963                session_id: session_id.to_string(),
1964                tenant_id: None,
1965            }),
1966        )
1967        .await
1968        .expect("store should succeed");
1969
1970        assert!(store_reply.valid);
1971        assert!(!store_reply.node_id.is_empty());
1972
1973        let Json(context_reply) = get_context_handler(
1974            State(state),
1975            HeaderMap::new(),
1976            Json(GetContextHttpRequest {
1977                session_id: session_id.to_string(),
1978                tenant_id: None,
1979                stability: 0.85,
1980                friction: 0.25,
1981                logic: 0.80,
1982                autonomy: 0.70,
1983                limit: Some(5),
1984                from_utc: None,
1985                to_utc: None,
1986                tiers: None,
1987                query_text: Some("gateway parser check".to_string()),
1988                query_embedding: None,
1989                alpha: None,
1990                beta: None,
1991                gamma: None,
1992                semantic_tags: None,
1993                tags_contains: None,
1994                link_rel: None,
1995                link_target: None,
1996                links_to_ref: None,
1997                tag_prefix: None,
1998                has_semantic_links: None,
1999            }),
2000        )
2001        .await
2002        .expect("get_context should succeed");
2003
2004        assert!(context_reply.retrieved >= 1);
2005        assert!(
2006            context_reply
2007                .nodes
2008                .iter()
2009                .any(|node| node.session_id == session_id)
2010        );
2011    }
2012
2013    #[tokio::test]
2014    async fn http_embedding_context_roundtrip_with_direct_embeddings() {
2015        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2016        let session_id = "http-embedding-context-session";
2017
2018        let Json(store_reply) = store_context_handler(
2019            State(state.clone()),
2020            HeaderMap::new(),
2021            Json(StoreContextHttpRequest {
2022                node: sample_node(session_id),
2023                session_id: session_id.to_string(),
2024                tenant_id: None,
2025            }),
2026        )
2027        .await
2028        .expect("store should succeed");
2029
2030        assert!(store_reply.valid);
2031
2032        let Json(context_reply) = get_embedding_context_handler(
2033            State(state),
2034            HeaderMap::new(),
2035            Json(GetEmbeddingContextHttpRequest {
2036                session_id: session_id.to_string(),
2037                tenant_id: None,
2038                stability: 0.85,
2039                friction: 0.25,
2040                logic: 0.80,
2041                autonomy: 0.70,
2042                limit: Some(5),
2043                from_utc: None,
2044                to_utc: None,
2045                tiers: None,
2046                rag_query_text: None,
2047                rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2048                avec_query_text: None,
2049                avec_embedding: Some(vec![0.2, 0.2, 0.2]),
2050                rag_weight: Some(0.7),
2051                avec_weight: Some(0.3),
2052                alpha: Some(0.65),
2053                beta: Some(0.35),
2054                tag_weight: None,
2055                semantic_tags: None,
2056                link_rel: None,
2057                link_target: None,
2058            }),
2059        )
2060        .await
2061        .expect("embedding context should succeed");
2062
2063        assert!(context_reply.retrieved >= 1);
2064    }
2065
2066    #[tokio::test]
2067    async fn http_embedding_context_rejects_mismatched_embedding_dimensions() {
2068        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2069
2070        let err = get_embedding_context_handler(
2071            State(state),
2072            HeaderMap::new(),
2073            Json(GetEmbeddingContextHttpRequest {
2074                session_id: "session".to_string(),
2075                tenant_id: None,
2076                stability: 0.8,
2077                friction: 0.2,
2078                logic: 0.8,
2079                autonomy: 0.7,
2080                limit: Some(5),
2081                from_utc: None,
2082                to_utc: None,
2083                tiers: None,
2084                rag_query_text: None,
2085                rag_embedding: Some(vec![0.1, 0.2, 0.3]),
2086                avec_query_text: None,
2087                avec_embedding: Some(vec![0.3, 0.2]),
2088                rag_weight: Some(0.7),
2089                avec_weight: Some(0.3),
2090                alpha: None,
2091                beta: None,
2092                tag_weight: None,
2093                semantic_tags: None,
2094                link_rel: None,
2095                link_target: None,
2096            }),
2097        )
2098        .await
2099        .expect_err("mismatched dimensions should fail");
2100
2101        assert_eq!(err.0, StatusCode::BAD_REQUEST);
2102    }
2103
2104    #[tokio::test]
2105    async fn http_evict_dry_run_then_delete_roundtrip() {
2106        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2107        let session_id = "http-evict-session";
2108
2109        let _ = store_context_handler(
2110            State(state.clone()),
2111            HeaderMap::new(),
2112            Json(StoreContextHttpRequest {
2113                node: sample_node(session_id),
2114                session_id: session_id.to_string(),
2115                tenant_id: None,
2116            }),
2117        )
2118        .await
2119        .expect("store should succeed");
2120
2121        let nodes_before = list_nodes_handler(
2122            State(state.clone()),
2123            HeaderMap::new(),
2124            Query(ListNodesQuery {
2125                tenant_id: None,
2126                session_id: Some(session_id.to_string()),
2127                limit: Some(10),
2128                semantic_tags: None,
2129                tags_contains: None,
2130                link_rel: None,
2131                link_target: None,
2132                links_to_ref: None,
2133                tag_prefix: None,
2134                has_semantic_links: None,
2135            }),
2136        )
2137        .await
2138        .expect("list should succeed");
2139        assert!(nodes_before.retrieved >= 1);
2140        let sync_key = nodes_before.nodes[0].sync_key.clone();
2141
2142        let Json(preview) = evict_handler(
2143            State(state.clone()),
2144            HeaderMap::new(),
2145            Json(EvictHttpRequest {
2146                tenant_id: None,
2147                session_id: session_id.to_string(),
2148                mode: None,
2149                sync_keys: Some(vec![sync_key.clone()]),
2150                node_ids: None,
2151                semantic_tags: None,
2152                tags_contains: None,
2153                link_rel: None,
2154                link_target: None,
2155                links_to_ref: None,
2156                tag_prefix: None,
2157                has_semantic_links: None,
2158                purge_session: None,
2159                dry_run: Some(true),
2160                force: Some(true),
2161                max_nodes: None,
2162                include_calibration: None,
2163                include_checkpoints: None,
2164                tiers: None,
2165            }),
2166        )
2167        .await
2168        .expect("dry-run evict should succeed");
2169
2170        assert_eq!(preview.deleted, 1);
2171        assert_eq!(preview.would_delete, vec![sync_key.clone()]);
2172
2173        let nodes_after_preview = list_nodes_handler(
2174            State(state.clone()),
2175            HeaderMap::new(),
2176            Query(ListNodesQuery {
2177                tenant_id: None,
2178                session_id: Some(session_id.to_string()),
2179                limit: Some(10),
2180                semantic_tags: None,
2181                tags_contains: None,
2182                link_rel: None,
2183                link_target: None,
2184                links_to_ref: None,
2185                tag_prefix: None,
2186                has_semantic_links: None,
2187            }),
2188        )
2189        .await
2190        .expect("list should succeed");
2191        assert_eq!(nodes_after_preview.retrieved, nodes_before.retrieved);
2192
2193        let Json(applied) = evict_handler(
2194            State(state.clone()),
2195            HeaderMap::new(),
2196            Json(EvictHttpRequest {
2197                tenant_id: None,
2198                session_id: session_id.to_string(),
2199                mode: None,
2200                sync_keys: Some(vec![sync_key]),
2201                node_ids: None,
2202                semantic_tags: None,
2203                tags_contains: None,
2204                link_rel: None,
2205                link_target: None,
2206                links_to_ref: None,
2207                tag_prefix: None,
2208                has_semantic_links: None,
2209                purge_session: None,
2210                dry_run: Some(false),
2211                force: Some(true),
2212                max_nodes: None,
2213                include_calibration: None,
2214                include_checkpoints: None,
2215                tiers: None,
2216            }),
2217        )
2218        .await
2219        .expect("evict should succeed");
2220        assert_eq!(applied.deleted, 1);
2221
2222        let nodes_after = list_nodes_handler(
2223            State(state),
2224            HeaderMap::new(),
2225            Query(ListNodesQuery {
2226                tenant_id: None,
2227                session_id: Some(session_id.to_string()),
2228                limit: Some(10),
2229                semantic_tags: None,
2230                tags_contains: None,
2231                link_rel: None,
2232                link_target: None,
2233                links_to_ref: None,
2234                tag_prefix: None,
2235                has_semantic_links: None,
2236            }),
2237        )
2238        .await
2239        .expect("list should succeed");
2240        assert_eq!(nodes_after.retrieved, 0);
2241    }
2242
2243    #[tokio::test]
2244    async fn grpc_service_roundtrip_for_calibrate_store_and_list() {
2245        let state = Arc::new(build_in_memory_state().await.expect("state should build"));
2246        let service = GrpcGatewayService::new(state);
2247        let session_id = "grpc-store-session";
2248
2249        let calibrate_reply = service
2250            .calibrate_session(Request::new(proto::CalibrateSessionRequest {
2251                session_id: session_id.to_string(),
2252                stability: 0.8,
2253                friction: 0.2,
2254                logic: 0.8,
2255                autonomy: 0.7,
2256                trigger: String::new(),
2257            }))
2258            .await
2259            .expect("gRPC calibrate should succeed")
2260            .into_inner();
2261
2262        assert_eq!(calibrate_reply.trigger, "manual");
2263
2264        let store_reply = service
2265            .store_context(Request::new(proto::StoreContextRequest {
2266                node: sample_node(session_id),
2267                session_id: session_id.to_string(),
2268            }))
2269            .await
2270            .expect("gRPC store should succeed")
2271            .into_inner();
2272
2273        assert!(store_reply.valid);
2274
2275        let rekey_reply = service
2276            .batch_rekey(Request::new(proto::BatchRekeyRequest {
2277                node_ids: vec![store_reply.node_id.clone()],
2278                target_session_id: session_id.to_string(),
2279                target_tenant_id: Some("default".to_string()),
2280                dry_run: Some(true),
2281                allow_merge: Some(false),
2282            }))
2283            .await
2284            .expect("gRPC batch_rekey should succeed")
2285            .into_inner();
2286
2287        assert_eq!(rekey_reply.requested_node_ids, 1);
2288        assert_eq!(rekey_reply.resolved_node_ids, 1);
2289        assert!(rekey_reply.scopes.iter().any(|scope| !scope.applied));
2290
2291        let list_reply = service
2292            .list_nodes(Request::new(proto::ListNodesRequest {
2293                limit: 50,
2294                session_id: Some(session_id.to_string()),
2295            }))
2296            .await
2297            .expect("gRPC list_nodes should succeed")
2298            .into_inner();
2299
2300        assert!(list_reply.retrieved >= 1);
2301        assert!(
2302            list_reply
2303                .nodes
2304                .iter()
2305                .any(|node| node.session_id == session_id)
2306        );
2307
2308        let context_reply = service
2309            .get_context(Request::new(proto::GetContextRequest {
2310                session_id: session_id.to_string(),
2311                stability: 0.8,
2312                friction: 0.2,
2313                logic: 0.8,
2314                autonomy: 0.7,
2315                limit: 5,
2316                query_text: None,
2317                query_embedding: vec![0.1, 0.2, 0.3],
2318                alpha: Some(0.7),
2319                beta: Some(0.3),
2320                from_utc: None,
2321                to_utc: None,
2322                tiers: Vec::new(),
2323            }))
2324            .await
2325            .expect("gRPC get_context should succeed")
2326            .into_inner();
2327
2328        assert!(context_reply.retrieved >= 1);
2329
2330        let embedding_context_reply = service
2331            .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2332                session_id: session_id.to_string(),
2333                stability: 0.8,
2334                friction: 0.2,
2335                logic: 0.8,
2336                autonomy: 0.7,
2337                limit: 5,
2338                rag_query_text: None,
2339                rag_embedding: vec![0.1, 0.2, 0.3],
2340                avec_query_text: None,
2341                avec_embedding: vec![0.2, 0.2, 0.2],
2342                rag_weight: Some(0.7),
2343                avec_weight: Some(0.3),
2344                alpha: Some(0.7),
2345                beta: Some(0.3),
2346                from_utc: None,
2347                to_utc: None,
2348                tiers: Vec::new(),
2349            }))
2350            .await
2351            .expect("gRPC get_embedding_context should succeed")
2352            .into_inner();
2353
2354        assert!(embedding_context_reply.retrieved >= 1);
2355
2356        let err = service
2357            .get_embedding_context(Request::new(proto::GetEmbeddingContextRequest {
2358                session_id: session_id.to_string(),
2359                stability: 0.8,
2360                friction: 0.2,
2361                logic: 0.8,
2362                autonomy: 0.7,
2363                limit: 5,
2364                rag_query_text: None,
2365                rag_embedding: vec![0.1, 0.2, 0.3],
2366                avec_query_text: None,
2367                avec_embedding: vec![0.2, 0.2],
2368                rag_weight: Some(0.7),
2369                avec_weight: Some(0.3),
2370                alpha: None,
2371                beta: None,
2372                from_utc: None,
2373                to_utc: None,
2374                tiers: Vec::new(),
2375            }))
2376            .await
2377            .expect_err("gRPC get_embedding_context should reject invalid dimensions");
2378
2379        assert_eq!(err.code(), Status::invalid_argument("x").code());
2380    }
2381}