Skip to main content

mnemo_grpc/
lib.rs

1//! gRPC API server for Mnemo.
2//!
3//! This crate exposes Mnemo's core memory operations (remember, recall, forget)
4//! over gRPC using [`tonic`]. The protobuf service definition lives in
5//! `proto/mnemo.proto` and code is generated at build time via `tonic-build`.
6//!
7//! # Usage
8//!
9//! ```rust,ignore
10//! use std::sync::Arc;
11//! use mnemo_grpc::router;
12//!
13//! let engine: Arc<mnemo_core::query::MnemoEngine> = /* ... */;
14//! let grpc_router = router(engine);
15//! ```
16
17use std::sync::Arc;
18
19use tonic::{Request, Response, Status};
20use uuid::Uuid;
21
22use mnemo_core::model::acl::Permission;
23use mnemo_core::model::delegation::{Delegation, DelegationScope};
24use mnemo_core::model::memory::{MemoryType, Scope, SourceType};
25use mnemo_core::query::MnemoEngine;
26use mnemo_core::query::branch::BranchRequest as CoreBranchRequest;
27use mnemo_core::query::checkpoint::CheckpointRequest as CoreCheckpointRequest;
28use mnemo_core::query::consolidate::ConsolidateRequest as CoreConsolidateRequest;
29use mnemo_core::query::forget::{
30    ForgetRequest as CoreForgetRequest, ForgetStrategy,
31    ForgetSubjectRequest as CoreForgetSubjectRequest,
32};
33use mnemo_core::query::merge::{MergeRequest as CoreMergeRequest, MergeStrategy};
34use mnemo_core::query::recall::RecallRequest as CoreRecallRequest;
35use mnemo_core::query::remember::RememberRequest as CoreRememberRequest;
36use mnemo_core::query::replay::ReplayRequest as CoreReplayRequest;
37use mnemo_core::query::share::ShareRequest as CoreShareRequest;
38
39// ---------------------------------------------------------------------------
40// Generated protobuf code
41// ---------------------------------------------------------------------------
42
43pub mod proto {
44    tonic::include_proto!("mnemo.v1");
45}
46
47use proto::mnemo_service_server::{MnemoService, MnemoServiceServer};
48use proto::{
49    BranchRequest as ProtoBranchRequest, BranchResponse as ProtoBranchResponse,
50    CheckpointRequest as ProtoCheckpointRequest, CheckpointResponse as ProtoCheckpointResponse,
51    ConsolidateRequest as ProtoConsolidateRequest, ConsolidateResponse as ProtoConsolidateResponse,
52    DelegateRequest as ProtoDelegateRequest, DelegateResponse as ProtoDelegateResponse,
53    ForgetError as ProtoForgetError, ForgetRequest as ProtoForgetRequest,
54    ForgetResponse as ProtoForgetResponse, ForgetSubjectRequest as ProtoForgetSubjectRequest,
55    ForgetSubjectResponse as ProtoForgetSubjectResponse, HealthRequest, HealthResponse,
56    MergeRequest as ProtoMergeRequest, MergeResponse as ProtoMergeResponse,
57    RecallRequest as ProtoRecallRequest, RecallResponse as ProtoRecallResponse,
58    RememberRequest as ProtoRememberRequest, RememberResponse as ProtoRememberResponse,
59    ReplayMemory as ProtoReplayMemory, ReplayRequest as ProtoReplayRequest,
60    ReplayResponse as ProtoReplayResponse, ScoredMemory as ProtoScoredMemory,
61    ShareRequest as ProtoShareRequest, ShareResponse as ProtoShareResponse,
62    TrajectoryAuditRequest as ProtoTrajectoryAuditRequest,
63    TrajectoryAuditResponse as ProtoTrajectoryAuditResponse,
64    TrajectoryFinding as ProtoTrajectoryFinding, VerifyRequest as ProtoVerifyRequest,
65    VerifyResponse as ProtoVerifyResponse,
66};
67
68// ---------------------------------------------------------------------------
69// Server implementation
70// ---------------------------------------------------------------------------
71
72/// gRPC server backed by a shared [`MnemoEngine`].
73#[derive(Clone)]
74pub struct MnemoGrpcServer {
75    engine: Arc<MnemoEngine>,
76}
77
78impl MnemoGrpcServer {
79    /// Create a new server wrapping the given engine.
80    pub fn new(engine: Arc<MnemoEngine>) -> Self {
81        Self { engine }
82    }
83}
84
85#[tonic::async_trait]
86impl MnemoService for MnemoGrpcServer {
87    // -- Remember ----------------------------------------------------------
88
89    async fn remember(
90        &self,
91        request: Request<ProtoRememberRequest>,
92    ) -> Result<Response<ProtoRememberResponse>, Status> {
93        let req = request.into_inner();
94
95        let memory_type = match req.memory_type {
96            Some(ref s) => match s.parse::<MemoryType>() {
97                Ok(mt) => Some(mt),
98                Err(_) => {
99                    return Err(Status::invalid_argument(format!(
100                        "invalid memory_type '{}': expected one of: episodic, semantic, procedural, working",
101                        s
102                    )));
103                }
104            },
105            None => None,
106        };
107
108        let scope = match req.scope {
109            Some(ref s) => match s.parse::<Scope>() {
110                Ok(sc) => Some(sc),
111                Err(_) => {
112                    return Err(Status::invalid_argument(format!(
113                        "invalid scope '{}': expected one of: private, shared, public, global",
114                        s
115                    )));
116                }
117            },
118            None => None,
119        };
120
121        let source_type = match req.source_type {
122            Some(ref s) => match s.parse::<SourceType>() {
123                Ok(st) => Some(st),
124                Err(_) => {
125                    return Err(Status::invalid_argument(format!(
126                        "invalid source_type '{}': expected one of: agent, human, system, user_input, tool_output, model_response, retrieval, consolidation, import",
127                        s
128                    )));
129                }
130            },
131            None => None,
132        };
133
134        let metadata: Option<serde_json::Value> = match req.metadata {
135            Some(ref s) => match serde_json::from_str(s) {
136                Ok(v) => Some(v),
137                Err(e) => {
138                    return Err(Status::invalid_argument(format!(
139                        "invalid metadata JSON: {}",
140                        e
141                    )));
142                }
143            },
144            None => None,
145        };
146
147        let tags = if req.tags.is_empty() {
148            None
149        } else {
150            Some(req.tags)
151        };
152
153        let related_to = if req.related_to.is_empty() {
154            None
155        } else {
156            Some(req.related_to)
157        };
158
159        let core_req = CoreRememberRequest {
160            content: req.content,
161            agent_id: req.agent_id,
162            memory_type,
163            scope,
164            importance: req.importance,
165            tags,
166            metadata,
167            source_type,
168            source_id: req.source_id,
169            org_id: req.org_id,
170            thread_id: req.thread_id,
171            ttl_seconds: req.ttl_seconds,
172            related_to,
173            decay_rate: req.decay_rate,
174            created_by: req.created_by,
175        };
176
177        let result = self
178            .engine
179            .remember(core_req)
180            .await
181            .map_err(core_error_to_status)?;
182
183        Ok(Response::new(ProtoRememberResponse {
184            id: result.id.to_string(),
185            content_hash: result.content_hash,
186        }))
187    }
188
189    // -- Recall ------------------------------------------------------------
190
191    async fn recall(
192        &self,
193        request: Request<ProtoRecallRequest>,
194    ) -> Result<Response<ProtoRecallResponse>, Status> {
195        let req = request.into_inner();
196
197        let memory_type = match req.memory_type {
198            Some(ref s) => match s.parse::<MemoryType>() {
199                Ok(mt) => Some(mt),
200                Err(_) => {
201                    return Err(Status::invalid_argument(format!(
202                        "invalid memory_type '{}': expected one of: episodic, semantic, procedural, working",
203                        s
204                    )));
205                }
206            },
207            None => None,
208        };
209
210        let scope = match req.scope {
211            Some(ref s) => match s.parse::<Scope>() {
212                Ok(sc) => Some(sc),
213                Err(_) => {
214                    return Err(Status::invalid_argument(format!(
215                        "invalid scope '{}': expected one of: private, shared, public, global",
216                        s
217                    )));
218                }
219            },
220            None => None,
221        };
222
223        let tags = if req.tags.is_empty() {
224            None
225        } else {
226            Some(req.tags)
227        };
228
229        let hybrid_weights = if req.hybrid_weights.is_empty() {
230            None
231        } else {
232            Some(req.hybrid_weights)
233        };
234
235        let orientation_cache_cfg = req.orientation_cache.map(|o| {
236            mnemo_core::query::orientation_cache::OrientationCacheConfig {
237                namespace: o.namespace,
238                token_budget: o.token_budget,
239                include_in_response: o.include_in_response.unwrap_or(true),
240                distill: o.distill.unwrap_or(true),
241            }
242        });
243        let core_req = CoreRecallRequest {
244            query: req.query,
245            agent_id: req.agent_id,
246            limit: req.limit.map(|l| l as usize),
247            memory_type,
248            memory_types: None,
249            scope,
250            min_importance: req.min_importance,
251            tags,
252            org_id: req.org_id,
253            strategy: req.strategy,
254            temporal_range: None,
255            recency_half_life_hours: None,
256            hybrid_weights,
257            rrf_k: req.rrf_k,
258            as_of: req.as_of,
259            explain: req.explain,
260            with_provenance: None,
261            mode: None,
262            current_fact_resolver: None,
263            orientation_cache: orientation_cache_cfg,
264            evidence_budget: None,
265            retained_token_budget: None,
266            domain_scope: None,
267            reasoning_trust: None,
268        };
269
270        let result = self
271            .engine
272            .recall(core_req)
273            .await
274            .map_err(core_error_to_status)?;
275
276        let memories: Vec<ProtoScoredMemory> = result
277            .memories
278            .into_iter()
279            .map(|m| ProtoScoredMemory {
280                id: m.id.to_string(),
281                content: m.content,
282                memory_type: format!("{:?}", m.memory_type),
283                importance: m.importance,
284                score: m.score,
285                created_at: m.created_at,
286                agent_id: m.agent_id,
287                scope: format!("{:?}", m.scope),
288                tags: m.tags,
289                metadata: m.metadata.to_string(),
290                access_count: m.access_count,
291                updated_at: m.updated_at,
292                score_breakdown: m.score_breakdown.map(|b| proto::ScoreBreakdown {
293                    vector: b.vector,
294                    bm25: b.bm25,
295                    graph: b.graph,
296                    recency: b.recency,
297                    rrf_rank: b.rrf_rank,
298                }),
299            })
300            .collect();
301
302        let total = result.total as u32;
303
304        let orientation_cache = result
305            .orientation_cache
306            .map(|r| proto::OrientationCacheResponse {
307                namespace: r.namespace,
308                entities: r
309                    .entities
310                    .into_iter()
311                    .map(|e| proto::OrientationEntry {
312                        key: e.key,
313                        value: e.value,
314                        freq: e.freq,
315                        token_estimate: e.token_estimate,
316                    })
317                    .collect(),
318                constants: r
319                    .constants
320                    .into_iter()
321                    .map(|e| proto::OrientationEntry {
322                        key: e.key,
323                        value: e.value,
324                        freq: e.freq,
325                        token_estimate: e.token_estimate,
326                    })
327                    .collect(),
328                schemas: r
329                    .schemas
330                    .into_iter()
331                    .map(|e| proto::OrientationEntry {
332                        key: e.key,
333                        value: e.value,
334                        freq: e.freq,
335                        token_estimate: e.token_estimate,
336                    })
337                    .collect(),
338                token_estimate: r.token_estimate,
339                budget: r.budget,
340                hit_count: r.hit_count,
341            });
342
343        // v0.5.1 — active-reconstruction belief-state node (MRAgent
344        // arXiv:2606.06036), present when strategy = "reconstruct".
345        let reconstruction = result.reconstruction.map(|b| proto::Reconstruction {
346            cue: b.cue,
347            summary: b.summary,
348            source_ids: b.source_ids.iter().map(|id| id.to_string()).collect(),
349            linked_context_ids: b
350                .linked_context_ids
351                .iter()
352                .map(|id| id.to_string())
353                .collect(),
354            confidence: b.confidence,
355        });
356
357        Ok(Response::new(ProtoRecallResponse {
358            memories,
359            total,
360            orientation_cache,
361            reconstruction,
362        }))
363    }
364
365    // -- Forget ------------------------------------------------------------
366
367    async fn forget(
368        &self,
369        request: Request<ProtoForgetRequest>,
370    ) -> Result<Response<ProtoForgetResponse>, Status> {
371        let req = request.into_inner();
372
373        let memory_ids: Vec<Uuid> = req
374            .memory_ids
375            .iter()
376            .map(|s| {
377                Uuid::parse_str(s)
378                    .map_err(|e| Status::invalid_argument(format!("invalid UUID '{s}': {e}")))
379            })
380            .collect::<Result<Vec<_>, _>>()?;
381
382        let strategy = match req.strategy {
383            Some(ref s) => {
384                let st = match s.as_str() {
385                    "soft_delete" => ForgetStrategy::SoftDelete,
386                    "hard_delete" => ForgetStrategy::HardDelete,
387                    "decay" => ForgetStrategy::Decay,
388                    "consolidate" => ForgetStrategy::Consolidate,
389                    "archive" => ForgetStrategy::Archive,
390                    "redact" => ForgetStrategy::Redact,
391                    _ => {
392                        return Err(Status::invalid_argument(format!(
393                            "invalid forget strategy '{}': expected one of: soft_delete, hard_delete, decay, consolidate, archive, redact",
394                            s
395                        )));
396                    }
397                };
398                Some(st)
399            }
400            None => None,
401        };
402
403        let core_req = CoreForgetRequest {
404            memory_ids,
405            agent_id: req.agent_id,
406            strategy,
407            criteria: None,
408        };
409
410        let result = self
411            .engine
412            .forget(core_req)
413            .await
414            .map_err(core_error_to_status)?;
415
416        let forgotten: Vec<String> = result.forgotten.iter().map(|id| id.to_string()).collect();
417
418        let errors: Vec<ProtoForgetError> = result
419            .errors
420            .into_iter()
421            .map(|e| ProtoForgetError {
422                id: e.id.to_string(),
423                error: e.error,
424            })
425            .collect();
426
427        Ok(Response::new(ProtoForgetResponse { forgotten, errors }))
428    }
429
430    // -- Health ------------------------------------------------------------
431
432    async fn health(
433        &self,
434        _request: Request<HealthRequest>,
435    ) -> Result<Response<HealthResponse>, Status> {
436        Ok(Response::new(HealthResponse {
437            status: "ok".to_string(),
438            version: env!("CARGO_PKG_VERSION").to_string(),
439        }))
440    }
441
442    // -- Share -------------------------------------------------------------
443
444    async fn share(
445        &self,
446        request: Request<ProtoShareRequest>,
447    ) -> Result<Response<ProtoShareResponse>, Status> {
448        let req = request.into_inner();
449        let memory_id = Uuid::parse_str(&req.memory_id)
450            .map_err(|e| Status::invalid_argument(format!("invalid UUID: {e}")))?;
451        let permission = match req.permission {
452            Some(ref s) => match s.parse::<Permission>() {
453                Ok(p) => Some(p),
454                Err(_) => {
455                    return Err(Status::invalid_argument(format!(
456                        "invalid permission '{}': expected one of: read, write, delete, share, delegate, admin",
457                        s
458                    )));
459                }
460            },
461            None => None,
462        };
463        let target_agent_ids = if req.target_agent_ids.is_empty() {
464            None
465        } else {
466            Some(req.target_agent_ids)
467        };
468
469        let core_req = CoreShareRequest {
470            memory_id,
471            agent_id: req.agent_id,
472            target_agent_id: req.target_agent_id,
473            target_agent_ids,
474            permission,
475            expires_in_hours: req.expires_in_hours,
476        };
477        let result = self
478            .engine
479            .share(core_req)
480            .await
481            .map_err(core_error_to_status)?;
482
483        Ok(Response::new(ProtoShareResponse {
484            acl_id: result.acl_id.to_string(),
485            acl_ids: result.acl_ids.iter().map(|id| id.to_string()).collect(),
486            memory_id: result.memory_id.to_string(),
487            shared_with: result.shared_with,
488            shared_with_all: result.shared_with_all,
489            permission: result.permission.to_string(),
490        }))
491    }
492
493    // -- Checkpoint --------------------------------------------------------
494
495    async fn checkpoint(
496        &self,
497        request: Request<ProtoCheckpointRequest>,
498    ) -> Result<Response<ProtoCheckpointResponse>, Status> {
499        let req = request.into_inner();
500        let state_snapshot: serde_json::Value = serde_json::from_str(&req.state_snapshot)
501            .map_err(|e| Status::invalid_argument(format!("invalid JSON state_snapshot: {e}")))?;
502        let metadata: Option<serde_json::Value> = match req.metadata {
503            Some(ref s) => match serde_json::from_str(s) {
504                Ok(v) => Some(v),
505                Err(e) => {
506                    return Err(Status::invalid_argument(format!(
507                        "invalid metadata JSON: {}",
508                        e
509                    )));
510                }
511            },
512            None => None,
513        };
514
515        let core_req = CoreCheckpointRequest {
516            thread_id: req.thread_id,
517            agent_id: req.agent_id,
518            branch_name: req.branch_name,
519            state_snapshot,
520            label: req.label,
521            metadata,
522        };
523        let result = self
524            .engine
525            .checkpoint(core_req)
526            .await
527            .map_err(core_error_to_status)?;
528
529        Ok(Response::new(ProtoCheckpointResponse {
530            checkpoint_id: result.id.to_string(),
531            parent_id: result.parent_id.map(|id| id.to_string()),
532            branch_name: result.branch_name,
533        }))
534    }
535
536    async fn consolidate(
537        &self,
538        request: Request<ProtoConsolidateRequest>,
539    ) -> Result<Response<ProtoConsolidateResponse>, Status> {
540        let req = request.into_inner();
541
542        let mut memory_ids = Vec::with_capacity(req.memory_ids.len());
543        for s in &req.memory_ids {
544            memory_ids.push(
545                Uuid::parse_str(s).map_err(|e| {
546                    Status::invalid_argument(format!("invalid memory id '{s}': {e}"))
547                })?,
548            );
549        }
550        let supersede = match req.supersede {
551            Some(ref s) => Some(Uuid::parse_str(s).map_err(|e| {
552                Status::invalid_argument(format!("invalid supersede id '{s}': {e}"))
553            })?),
554            None => None,
555        };
556        let metadata: Option<serde_json::Value> = match req.metadata {
557            Some(ref s) => Some(
558                serde_json::from_str(s)
559                    .map_err(|e| Status::invalid_argument(format!("invalid metadata JSON: {e}")))?,
560            ),
561            None => None,
562        };
563
564        let mut core_req = CoreConsolidateRequest::new(memory_ids, req.topic_name);
565        core_req.agent_id = req.agent_id;
566        core_req.summary = req.summary;
567        core_req.supersede = supersede;
568        core_req.thread_id = req.thread_id;
569        core_req.metadata = metadata;
570
571        let result = self
572            .engine
573            .consolidate(core_req)
574            .await
575            .map_err(core_error_to_status)?;
576
577        Ok(Response::new(ProtoConsolidateResponse {
578            topic_document_id: result.topic_document_id.to_string(),
579            topic_name: result.topic_name,
580            source_count: result.source_count as u64,
581            version: result.version,
582            superseded_id: result.superseded_id.map(|id| id.to_string()),
583            member_ids: result.member_ids.iter().map(|id| id.to_string()).collect(),
584            content_hash: result.content_hash,
585            consolidation_event_id: result.consolidation_event_id.to_string(),
586            revision_event_id: result.revision_event_id.map(|id| id.to_string()),
587        }))
588    }
589
590    // -- Branch ------------------------------------------------------------
591
592    async fn branch(
593        &self,
594        request: Request<ProtoBranchRequest>,
595    ) -> Result<Response<ProtoBranchResponse>, Status> {
596        let req = request.into_inner();
597        let source_checkpoint_id = match req.source_checkpoint_id {
598            Some(ref s) => match Uuid::parse_str(s) {
599                Ok(id) => Some(id),
600                Err(e) => {
601                    return Err(Status::invalid_argument(format!(
602                        "invalid source_checkpoint_id '{}': {}",
603                        s, e
604                    )));
605                }
606            },
607            None => None,
608        };
609
610        let core_req = CoreBranchRequest {
611            thread_id: req.thread_id,
612            agent_id: req.agent_id,
613            new_branch_name: req.new_branch_name,
614            source_checkpoint_id,
615            source_branch: req.source_branch,
616        };
617        let result = self
618            .engine
619            .branch(core_req)
620            .await
621            .map_err(core_error_to_status)?;
622
623        Ok(Response::new(ProtoBranchResponse {
624            checkpoint_id: result.checkpoint_id.to_string(),
625            branch_name: result.branch_name,
626            source_checkpoint_id: result.source_checkpoint_id.to_string(),
627        }))
628    }
629
630    // -- Merge -------------------------------------------------------------
631
632    async fn merge(
633        &self,
634        request: Request<ProtoMergeRequest>,
635    ) -> Result<Response<ProtoMergeResponse>, Status> {
636        let req = request.into_inner();
637        let strategy = match req.strategy {
638            Some(ref s) => {
639                let st = match s.as_str() {
640                    "full_merge" => MergeStrategy::FullMerge,
641                    "cherry_pick" => MergeStrategy::CherryPick,
642                    "squash" => MergeStrategy::Squash,
643                    _ => {
644                        return Err(Status::invalid_argument(format!(
645                            "invalid merge strategy '{}': expected one of: full_merge, cherry_pick, squash",
646                            s
647                        )));
648                    }
649                };
650                Some(st)
651            }
652            None => None,
653        };
654        let cherry_pick_ids = if req.cherry_pick_ids.is_empty() {
655            None
656        } else {
657            let ids: Result<Vec<Uuid>, _> = req
658                .cherry_pick_ids
659                .iter()
660                .map(|s| Uuid::parse_str(s))
661                .collect();
662            Some(ids.map_err(|e| Status::invalid_argument(format!("invalid UUID: {e}")))?)
663        };
664
665        let core_req = CoreMergeRequest {
666            thread_id: req.thread_id,
667            agent_id: req.agent_id,
668            source_branch: req.source_branch,
669            target_branch: req.target_branch,
670            strategy,
671            cherry_pick_ids,
672        };
673        let result = self
674            .engine
675            .merge(core_req)
676            .await
677            .map_err(core_error_to_status)?;
678
679        Ok(Response::new(ProtoMergeResponse {
680            checkpoint_id: result.checkpoint_id.to_string(),
681            target_branch: result.target_branch,
682            merged_memory_count: result.merged_memory_count as u32,
683        }))
684    }
685
686    // -- Replay ------------------------------------------------------------
687
688    async fn replay(
689        &self,
690        request: Request<ProtoReplayRequest>,
691    ) -> Result<Response<ProtoReplayResponse>, Status> {
692        let req = request.into_inner();
693        let checkpoint_id = match req.checkpoint_id {
694            Some(ref s) => match Uuid::parse_str(s) {
695                Ok(id) => Some(id),
696                Err(e) => {
697                    return Err(Status::invalid_argument(format!(
698                        "invalid checkpoint_id '{}': {}",
699                        s, e
700                    )));
701                }
702            },
703            None => None,
704        };
705
706        let core_req = CoreReplayRequest {
707            thread_id: req.thread_id,
708            agent_id: req.agent_id,
709            checkpoint_id,
710            branch_name: req.branch_name,
711            as_of: req.as_of,
712        };
713        let result = self
714            .engine
715            .replay(core_req)
716            .await
717            .map_err(core_error_to_status)?;
718
719        let checkpoint_json =
720            serde_json::to_string(&result.checkpoint).unwrap_or_else(|_| "{}".to_string());
721        let memories: Vec<ProtoReplayMemory> = result
722            .memories
723            .iter()
724            .map(|m| ProtoReplayMemory {
725                id: m.id.to_string(),
726                content: m.content.clone(),
727                memory_type: format!("{:?}", m.memory_type),
728                created_at: m.created_at.clone(),
729            })
730            .collect();
731
732        let (chain_valid, chain_total, chain_verified) =
733            if let Some(ref cv) = result.chain_verification {
734                (
735                    Some(cv.valid),
736                    Some(cv.total_records as u32),
737                    Some(cv.verified_records as u32),
738                )
739            } else {
740                (None, None, None)
741            };
742
743        Ok(Response::new(ProtoReplayResponse {
744            checkpoint_json,
745            memories,
746            event_count: result.events.len() as u32,
747            chain_valid,
748            chain_total,
749            chain_verified,
750        }))
751    }
752
753    // -- Delegate ----------------------------------------------------------
754
755    async fn delegate(
756        &self,
757        request: Request<ProtoDelegateRequest>,
758    ) -> Result<Response<ProtoDelegateResponse>, Status> {
759        let req = request.into_inner();
760        let permission: Permission = req
761            .permission
762            .parse()
763            .map_err(|e: mnemo_core::error::Error| Status::invalid_argument(e.to_string()))?;
764
765        let scope = if !req.memory_ids.is_empty() {
766            let ids: Vec<Uuid> = req
767                .memory_ids
768                .iter()
769                .map(|s| {
770                    Uuid::parse_str(s)
771                        .map_err(|e| Status::invalid_argument(format!("invalid UUID: {e}")))
772                })
773                .collect::<Result<Vec<_>, _>>()?;
774            DelegationScope::ByMemoryId(ids)
775        } else if !req.tags.is_empty() {
776            DelegationScope::ByTag(req.tags)
777        } else {
778            DelegationScope::AllMemories
779        };
780
781        let now = chrono::Utc::now();
782        let expires_at = req
783            .expires_in_hours
784            .map(|h| (now + chrono::Duration::seconds((h * 3600.0) as i64)).to_rfc3339());
785
786        let delegation = Delegation {
787            id: Uuid::now_v7(),
788            delegator_id: req.delegator_id,
789            delegate_id: req.delegate_id,
790            permission,
791            scope,
792            max_depth: req.max_depth.unwrap_or(0),
793            current_depth: 0,
794            parent_delegation_id: None,
795            created_at: now.to_rfc3339(),
796            expires_at,
797            revoked_at: None,
798        };
799
800        self.engine
801            .storage
802            .insert_delegation(&delegation)
803            .await
804            .map_err(core_error_to_status)?;
805
806        Ok(Response::new(ProtoDelegateResponse {
807            delegation_id: delegation.id.to_string(),
808        }))
809    }
810
811    // -- Verify ------------------------------------------------------------
812
813    async fn verify(
814        &self,
815        request: Request<ProtoVerifyRequest>,
816    ) -> Result<Response<ProtoVerifyResponse>, Status> {
817        let req = request.into_inner();
818        let result = self
819            .engine
820            .verify_integrity(req.agent_id, req.thread_id.as_deref())
821            .await
822            .map_err(core_error_to_status)?;
823
824        Ok(Response::new(ProtoVerifyResponse {
825            valid: result.valid,
826            total_records: result.total_records as u32,
827            verified_records: result.verified_records as u32,
828            first_broken_at: result.first_broken_at.map(|id| id.to_string()),
829            error_message: result.error_message,
830        }))
831    }
832
833    // -- TrajectoryAudit ---------------------------------------------------
834    //
835    // GEM arXiv:2605.26252. Complements `verify` (per-record chain
836    // integrity) on the orthogonal trajectory axis. The four signals
837    // are computed in `mnemo_compliance::trajectory::trajectory_audit`;
838    // this RPC is a thin events-fetch + handoff layer mirroring the
839    // `verify` plumbing above.
840
841    async fn trajectory_audit(
842        &self,
843        request: Request<ProtoTrajectoryAuditRequest>,
844    ) -> Result<Response<ProtoTrajectoryAuditResponse>, Status> {
845        let req = request.into_inner();
846        let agent_id = req
847            .agent_id
848            .clone()
849            .unwrap_or_else(|| self.engine.default_agent_id.clone());
850
851        // list_events returns DESC order — reverse to chronological
852        // before handing to the compliance function (same contract as
853        // export_audit_log).
854        let mut events = self
855            .engine
856            .storage
857            .list_events(&agent_id, mnemo_core::query::MAX_BATCH_QUERY_LIMIT, 0)
858            .await
859            .map_err(core_error_to_status)?;
860        events.reverse();
861
862        let mut audit_req = mnemo_compliance::trajectory::TrajectoryAuditRequest {
863            agent_id: Some(agent_id),
864            thread_id: req.thread_id.clone(),
865            ..Default::default()
866        };
867        if let Some(c) = req.active_bank_ceiling {
868            audit_req.active_bank_ceiling = c as usize;
869        }
870        if let Some(k) = req.fact_key {
871            audit_req.fact_key = k;
872        }
873        if !req.named_forget_strategies.is_empty() {
874            audit_req.named_forget_strategies = req.named_forget_strategies;
875        }
876
877        let report = mnemo_compliance::trajectory::trajectory_audit(&events, &audit_req)
878            .map_err(|e| Status::invalid_argument(e.to_string()))?;
879        let report_json = serde_json::to_string(&report)
880            .map_err(|e| Status::internal(format!("report serialisation: {e}")))?;
881
882        Ok(Response::new(ProtoTrajectoryAuditResponse {
883            scope_label: report.scope_label.clone(),
884            event_count: report.event_count as u32,
885            all_ok: report.all_ok(),
886            unregulated_growth: Some(ProtoTrajectoryFinding {
887                severity: severity_to_str(report.unregulated_growth.severity).to_string(),
888                count: report.unregulated_growth.breach_count as u32,
889            }),
890            missing_semantic_revision: Some(ProtoTrajectoryFinding {
891                severity: severity_to_str(report.missing_semantic_revision.severity).to_string(),
892                count: report.missing_semantic_revision.stale_facts.len() as u32,
893            }),
894            capacity_driven_forgetting: Some(ProtoTrajectoryFinding {
895                severity: severity_to_str(report.capacity_driven_forgetting.severity).to_string(),
896                count: report
897                    .capacity_driven_forgetting
898                    .unlabelled_forget_event_ids
899                    .len() as u32,
900            }),
901            read_only_retrieval: Some(ProtoTrajectoryFinding {
902                severity: severity_to_str(report.read_only_retrieval.severity).to_string(),
903                count: report.read_only_retrieval.read_only_scopes.len() as u32,
904            }),
905            report_json,
906        }))
907    }
908
909    // -- ForgetSubject -----------------------------------------------------
910
911    async fn forget_subject(
912        &self,
913        request: Request<ProtoForgetSubjectRequest>,
914    ) -> Result<Response<ProtoForgetSubjectResponse>, Status> {
915        let req = request.into_inner();
916
917        let strategy = match req.strategy.as_deref().unwrap_or("redact") {
918            "redact" => ForgetStrategy::Redact,
919            "hard_delete" => ForgetStrategy::HardDelete,
920            "soft_delete" => ForgetStrategy::SoftDelete,
921            other => {
922                return Err(Status::invalid_argument(format!(
923                    "invalid forget_subject strategy '{}': expected one of: redact, hard_delete, soft_delete",
924                    other
925                )));
926            }
927        };
928
929        let core_req = CoreForgetSubjectRequest {
930            subject_id: req.subject_id,
931            agent_id: req.agent_id,
932            strategy,
933        };
934
935        let result = self
936            .engine
937            .forget_subject(core_req)
938            .await
939            .map_err(core_error_to_status)?;
940
941        let errors: Vec<ProtoForgetError> = result
942            .errors
943            .into_iter()
944            .map(|e| ProtoForgetError {
945                id: e.id.to_string(),
946                error: e.error,
947            })
948            .collect();
949
950        let strategy_str = match result.strategy {
951            ForgetStrategy::SoftDelete => "soft_delete",
952            ForgetStrategy::HardDelete => "hard_delete",
953            ForgetStrategy::Decay => "decay",
954            ForgetStrategy::Consolidate => "consolidate",
955            ForgetStrategy::Archive => "archive",
956            ForgetStrategy::Redact => "redact",
957        }
958        .to_string();
959
960        Ok(Response::new(ProtoForgetSubjectResponse {
961            subject_id: result.subject_id,
962            strategy: strategy_str,
963            matched: result.matched as u32,
964            forgotten: result.forgotten.iter().map(|id| id.to_string()).collect(),
965            cascaded_events: result.cascaded_events as u32,
966            errors,
967        }))
968    }
969}
970
971// ---------------------------------------------------------------------------
972// Router constructor
973// ---------------------------------------------------------------------------
974
975/// Build a [`tonic::transport::server::Router`] serving the Mnemo gRPC API.
976///
977/// The returned router can be composed with other tonic services or served
978/// directly via `tonic::transport::Server`.
979///
980/// # Example
981///
982/// ```rust,ignore
983/// use std::sync::Arc;
984/// use mnemo_grpc::router;
985///
986/// let engine: Arc<mnemo_core::query::MnemoEngine> = /* ... */;
987/// let grpc_router = router(engine);
988/// tonic::transport::Server::builder()
989///     .add_routes(grpc_router.into_service())
990///     .serve("[::1]:50051".parse().unwrap())
991///     .await
992///     .unwrap();
993/// ```
994/// Reads the bearer secret from `MNEMO_AUTH_TOKEN`. When set (non-empty),
995/// every RPC must carry a matching `authorization` metadata value or it is
996/// rejected with `UNAUTHENTICATED`. When unset, the server runs **open** and
997/// logs a warning.
998pub fn router(engine: Arc<MnemoEngine>) -> tonic::transport::server::Router {
999    let token = std::env::var("MNEMO_AUTH_TOKEN")
1000        .ok()
1001        .filter(|s| !s.is_empty());
1002    router_with_auth(engine, token)
1003}
1004
1005/// Like [`router`] but with the bearer secret passed explicitly. `Some(token)`
1006/// installs a tonic interceptor that requires `authorization: <token>` (a bare
1007/// token or `Bearer <token>`) on every RPC; `None` runs open (with a warning).
1008pub fn router_with_auth(
1009    engine: Arc<MnemoEngine>,
1010    auth_token: Option<String>,
1011) -> tonic::transport::server::Router {
1012    let svc = MnemoGrpcServer::new(engine);
1013    match auth_token {
1014        Some(token) if !token.is_empty() => {
1015            tracing::info!(
1016                "gRPC bearer-token auth ENABLED (authorization metadata = MNEMO_AUTH_TOKEN)"
1017            );
1018            let expected = Arc::new(token);
1019            let interceptor = move |req: Request<()>| -> Result<Request<()>, Status> {
1020                let provided = req
1021                    .metadata()
1022                    .get("authorization")
1023                    .and_then(|v| v.to_str().ok());
1024                if mnemo_core::auth::bearer_token_matches(provided, &expected) {
1025                    Ok(req)
1026                } else {
1027                    Err(Status::unauthenticated(
1028                        "missing or invalid bearer token (set `authorization` metadata)",
1029                    ))
1030                }
1031            };
1032            tonic::transport::Server::builder()
1033                .add_service(MnemoServiceServer::with_interceptor(svc, interceptor))
1034        }
1035        _ => {
1036            tracing::warn!(
1037                "gRPC API running WITHOUT authentication — set MNEMO_AUTH_TOKEN to require a \
1038                 bearer token. Do not expose an unauthenticated memory server."
1039            );
1040            tonic::transport::Server::builder().add_service(MnemoServiceServer::new(svc))
1041        }
1042    }
1043}
1044
1045// ---------------------------------------------------------------------------
1046// Helpers
1047// ---------------------------------------------------------------------------
1048
1049/// Map a `mnemo_core::error::Error` to a tonic `Status`.
1050fn core_error_to_status(err: mnemo_core::error::Error) -> Status {
1051    use mnemo_core::error::Error;
1052
1053    match err {
1054        Error::Validation(msg) => Status::invalid_argument(msg),
1055        Error::PermissionDenied(msg) => Status::permission_denied(msg),
1056        Error::NotFound(msg) => Status::not_found(msg),
1057        other => Status::internal(other.to_string()),
1058    }
1059}
1060
1061/// Render a [`mnemo_compliance::Severity`] as the lowercase string the
1062/// `TrajectoryFinding` proto carries.
1063fn severity_to_str(s: mnemo_compliance::Severity) -> &'static str {
1064    match s {
1065        mnemo_compliance::Severity::Ok => "ok",
1066        mnemo_compliance::Severity::Warn => "warn",
1067        mnemo_compliance::Severity::Fail => "fail",
1068    }
1069}
1070
1071// ---------------------------------------------------------------------------
1072// Tests
1073// ---------------------------------------------------------------------------
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    #[test]
1080    fn core_error_maps_correctly() {
1081        let validation =
1082            core_error_to_status(mnemo_core::error::Error::Validation("bad input".into()));
1083        assert_eq!(validation.code(), tonic::Code::InvalidArgument);
1084
1085        let perm = core_error_to_status(mnemo_core::error::Error::PermissionDenied(
1086            "forbidden".into(),
1087        ));
1088        assert_eq!(perm.code(), tonic::Code::PermissionDenied);
1089
1090        let not_found = core_error_to_status(mnemo_core::error::Error::NotFound("missing".into()));
1091        assert_eq!(not_found.code(), tonic::Code::NotFound);
1092    }
1093}