Skip to main content

mnemo_rest/
handlers.rs

1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Deserialize;
8use uuid::Uuid;
9
10use mnemo_core::error::Error as CoreError;
11use mnemo_core::hash::compute_content_hash;
12use mnemo_core::model::acl::Permission;
13use mnemo_core::model::delegation::{Delegation, DelegationScope};
14use mnemo_core::model::event::{AgentEvent, EventType};
15use mnemo_core::model::memory::{MemoryType, Scope};
16use mnemo_core::model::write_provenance::WriteProvenance;
17use mnemo_core::query::MnemoEngine;
18use mnemo_core::query::branch::{BranchRequest, BranchResponse};
19use mnemo_core::query::checkpoint::{CheckpointRequest, CheckpointResponse};
20use mnemo_core::query::consolidate::{ConsolidateRequest, ConsolidateResponse};
21use mnemo_core::query::forget::{
22    ForgetRequest, ForgetResponse, ForgetStrategy, ForgetSubjectRequest, ForgetSubjectResponse,
23};
24use mnemo_core::query::merge::{MergeRequest, MergeResponse};
25use mnemo_core::query::recall::{RecallRequest, RecallResponse};
26use mnemo_core::query::remember::{RememberRequest, RememberResponse};
27use mnemo_core::query::replay::{ReplayRequest, ReplayResponse};
28use mnemo_core::query::share::{ShareRequest, ShareResponse};
29
30type AppState = Arc<MnemoEngine>;
31
32// ---------------------------------------------------------------------------
33// Error handling
34// ---------------------------------------------------------------------------
35
36pub struct AppError(CoreError);
37
38impl IntoResponse for AppError {
39    fn into_response(self) -> Response {
40        let (status, msg) = match &self.0 {
41            CoreError::Validation(m) => (StatusCode::BAD_REQUEST, m.clone()),
42            CoreError::PermissionDenied(m) => (StatusCode::FORBIDDEN, m.clone()),
43            CoreError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
44            other => {
45                tracing::error!("internal error: {other}");
46                (
47                    StatusCode::INTERNAL_SERVER_ERROR,
48                    "internal server error".to_string(),
49                )
50            }
51        };
52        (status, Json(serde_json::json!({"error": msg}))).into_response()
53    }
54}
55
56impl From<CoreError> for AppError {
57    fn from(e: CoreError) -> Self {
58        AppError(e)
59    }
60}
61
62// ---------------------------------------------------------------------------
63// Query / body helper structs
64// ---------------------------------------------------------------------------
65
66#[derive(Debug, Deserialize)]
67pub struct RecallParams {
68    pub query: String,
69    pub agent_id: Option<String>,
70    pub limit: Option<usize>,
71    pub memory_type: Option<String>,
72    pub scope: Option<String>,
73    pub min_importance: Option<f32>,
74    pub tags: Option<String>,
75    pub org_id: Option<String>,
76    pub strategy: Option<String>,
77    pub as_of: Option<String>,
78    pub memory_types: Option<String>,
79    pub hybrid_weights: Option<String>,
80    pub rrf_k: Option<f32>,
81    pub explain: Option<bool>,
82    /// v0.4.7 — opt-in current-fact resolver. Set the metadata key
83    /// to scope fact identity by (typical: `fact_id`). When set,
84    /// the response returns the most-recent write per fact group.
85    pub current_fact_key: Option<String>,
86    /// v0.4.7 — include the supersession chain in the response.
87    /// Honored only when `current_fact_key` is also set.
88    pub current_fact_include_chain: Option<bool>,
89    /// v0.4.8 — opt-in orientation cache. When `true` AND the
90    /// engine has an `OrientationCacheStore` attached, the recall
91    /// updates a per-namespace, constant-token "context map" and
92    /// returns a bounded rendering in
93    /// `response.orientation_cache`. PEEK-anchored
94    /// (arXiv:2605.19932).
95    pub orientation_cache: Option<bool>,
96    /// v0.4.8 — explicit namespace label. When omitted, the engine
97    /// derives one from `(org_id, agent_id)`.
98    pub orientation_namespace: Option<String>,
99    /// v0.4.8 — token budget for the rendered map. Defaults to
100    /// 512 when omitted.
101    pub orientation_token_budget: Option<u32>,
102    /// v0.4.8 — include the rendered map in the response.
103    /// Defaults to `true` when omitted.
104    pub orientation_include_in_response: Option<bool>,
105    /// v0.4.8 — run the Distiller and update the in-process store.
106    /// Defaults to `true` when omitted; set to `false` for warm-up
107    /// or inspection calls that should not mutate the map.
108    pub orientation_distill: Option<bool>,
109}
110
111#[derive(Debug, Deserialize)]
112pub struct ForgetParams {
113    pub strategy: Option<String>,
114    pub agent_id: Option<String>,
115}
116
117#[derive(Debug, Deserialize)]
118pub struct ShareBody {
119    pub target_agent_id: String,
120    pub target_agent_ids: Option<Vec<String>>,
121    pub permission: Option<String>,
122    pub expires_in_hours: Option<f64>,
123    pub agent_id: Option<String>,
124}
125
126#[derive(Debug, Deserialize)]
127pub struct VerifyBody {
128    pub agent_id: Option<String>,
129    pub thread_id: Option<String>,
130}
131
132#[derive(Debug, Deserialize)]
133pub struct TrajectoryAuditBody {
134    pub agent_id: Option<String>,
135    pub thread_id: Option<String>,
136    pub active_bank_ceiling: Option<usize>,
137    pub fact_key: Option<String>,
138    pub named_forget_strategies: Option<Vec<String>>,
139}
140
141#[derive(Debug, Deserialize)]
142pub struct DelegateRequest {
143    pub delegate_id: String,
144    pub permission: String,
145    pub memory_ids: Option<Vec<String>>,
146    pub tags: Option<Vec<String>>,
147    pub max_depth: Option<u32>,
148    pub expires_in_hours: Option<f64>,
149    /// The agent requesting delegation. Required — the server will verify
150    /// this agent has `Delegate` permission on the target memories.
151    pub agent_id: Option<String>,
152}
153
154// ---------------------------------------------------------------------------
155// Handlers
156// ---------------------------------------------------------------------------
157
158/// POST /v1/memories -- store a new memory.
159pub async fn remember_handler(
160    State(engine): State<AppState>,
161    Json(request): Json<RememberRequest>,
162) -> Result<Json<RememberResponse>, AppError> {
163    let response = engine.remember(request).await?;
164    Ok(Json(response))
165}
166
167/// GET /v1/memories?query=...&limit=...&memory_type=...&scope=...&strategy=...
168pub async fn recall_handler(
169    State(engine): State<AppState>,
170    Query(params): Query<RecallParams>,
171) -> Result<Json<RecallResponse>, AppError> {
172    let memory_type = match params.memory_type.as_deref() {
173        Some(s) => Some(s.parse::<MemoryType>().map_err(|_| {
174            AppError(CoreError::Validation(format!(
175                "invalid memory_type '{}': expected one of: episodic, semantic, procedural, working",
176                s
177            )))
178        })?),
179        None => None,
180    };
181
182    let scope = match params.scope.as_deref() {
183        Some(s) => Some(s.parse::<Scope>().map_err(|_| {
184            AppError(CoreError::Validation(format!(
185                "invalid scope '{}': expected one of: private, shared, public, global",
186                s
187            )))
188        })?),
189        None => None,
190    };
191
192    let tags = params.tags.as_deref().map(|t| {
193        t.split(',')
194            .map(|s| s.trim().to_string())
195            .collect::<Vec<_>>()
196    });
197
198    let memory_types = match params.memory_types.as_deref() {
199        Some(s) => {
200            let mut parsed = Vec::new();
201            for t in s.split(',') {
202                let trimmed = t.trim();
203                let mt = trimmed.parse::<MemoryType>().map_err(|_| {
204                    AppError(CoreError::Validation(format!(
205                        "invalid memory_type '{}' in memory_types: expected one of: episodic, semantic, procedural, working",
206                        trimmed
207                    )))
208                })?;
209                parsed.push(mt);
210            }
211            Some(parsed)
212        }
213        None => None,
214    };
215
216    let hybrid_weights = match params.hybrid_weights.as_deref() {
217        Some(s) => {
218            let mut weights = Vec::new();
219            for w in s.split(',') {
220                let trimmed = w.trim();
221                let val = trimmed.parse::<f32>().map_err(|_| {
222                    AppError(CoreError::Validation(format!(
223                        "invalid weight '{}' in hybrid_weights: expected a floating-point number",
224                        trimmed
225                    )))
226                })?;
227                weights.push(val);
228            }
229            Some(weights)
230        }
231        None => None,
232    };
233
234    let request = RecallRequest {
235        query: params.query,
236        agent_id: params.agent_id,
237        limit: params.limit,
238        memory_type,
239        memory_types,
240        scope,
241        min_importance: params.min_importance,
242        tags,
243        org_id: params.org_id,
244        strategy: params.strategy,
245        temporal_range: None,
246        recency_half_life_hours: None,
247        hybrid_weights,
248        rrf_k: params.rrf_k,
249        as_of: params.as_of,
250        explain: params.explain,
251        with_provenance: None,
252        mode: None,
253        current_fact_resolver: params.current_fact_key.map(|fact_key| {
254            mnemo_core::query::current_fact_resolver::CurrentFactResolverConfig {
255                fact_key,
256                include_supersession_chain: params.current_fact_include_chain.unwrap_or(false),
257            }
258        }),
259        orientation_cache: if params.orientation_cache.unwrap_or(false) {
260            Some(
261                mnemo_core::query::orientation_cache::OrientationCacheConfig {
262                    namespace: params.orientation_namespace.clone(),
263                    token_budget: params.orientation_token_budget,
264                    include_in_response: params.orientation_include_in_response.unwrap_or(true),
265                    distill: params.orientation_distill.unwrap_or(true),
266                },
267            )
268        } else {
269            None
270        },
271        evidence_budget: None,
272        retained_token_budget: None,
273        domain_scope: None,
274        reasoning_trust: None,
275    };
276
277    let response = engine.recall(request).await?;
278    Ok(Json(response))
279}
280
281/// GET /v1/memories/:id -- retrieve a single memory by UUID.
282pub async fn get_memory_handler(
283    State(engine): State<AppState>,
284    Path(id): Path<Uuid>,
285) -> Result<Json<serde_json::Value>, AppError> {
286    let record = engine
287        .storage
288        .get_memory(id)
289        .await?
290        .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
291
292    let value = serde_json::json!({
293        "id": record.id,
294        "agent_id": record.agent_id,
295        "content": record.content,
296        "memory_type": record.memory_type,
297        "scope": record.scope,
298        "importance": record.importance,
299        "tags": record.tags,
300        "metadata": record.metadata,
301        "source_type": record.source_type,
302        "source_id": record.source_id,
303        "consolidation_state": record.consolidation_state,
304        "access_count": record.access_count,
305        "org_id": record.org_id,
306        "thread_id": record.thread_id,
307        "created_at": record.created_at,
308        "updated_at": record.updated_at,
309        "last_accessed_at": record.last_accessed_at,
310        "expires_at": record.expires_at,
311        "deleted_at": record.deleted_at,
312        "decay_rate": record.decay_rate,
313        "created_by": record.created_by,
314        "version": record.version,
315        "prev_version_id": record.prev_version_id,
316        "quarantined": record.quarantined,
317        "quarantine_reason": record.quarantine_reason,
318    });
319
320    Ok(Json(value))
321}
322
323/// DELETE /v1/memories/:id?strategy=soft_delete|hard_delete|decay|consolidate|archive
324pub async fn forget_handler(
325    State(engine): State<AppState>,
326    Path(id): Path<Uuid>,
327    Query(params): Query<ForgetParams>,
328) -> Result<Json<ForgetResponse>, AppError> {
329    let strategy = match params.strategy.as_deref() {
330        Some(s) => Some(match s {
331            "soft_delete" => ForgetStrategy::SoftDelete,
332            "hard_delete" => ForgetStrategy::HardDelete,
333            "decay" => ForgetStrategy::Decay,
334            "consolidate" => ForgetStrategy::Consolidate,
335            "archive" => ForgetStrategy::Archive,
336            "redact" => ForgetStrategy::Redact,
337            other => {
338                return Err(AppError(CoreError::Validation(format!(
339                    "invalid forget strategy '{}': expected one of: soft_delete, hard_delete, decay, consolidate, archive, redact",
340                    other
341                ))));
342            }
343        }),
344        None => None,
345    };
346
347    let request = ForgetRequest {
348        memory_ids: vec![id],
349        agent_id: params.agent_id,
350        strategy,
351        criteria: None,
352    };
353
354    let response = engine.forget(request).await?;
355    Ok(Json(response))
356}
357
358#[derive(Debug, Deserialize)]
359pub struct ForgetSubjectBody {
360    pub subject_id: String,
361    pub strategy: Option<String>,
362    pub agent_id: Option<String>,
363}
364
365/// POST /v1/forget_subject — GDPR / DPDPA-aligned subject erasure.
366pub async fn forget_subject_handler(
367    State(engine): State<AppState>,
368    Json(body): Json<ForgetSubjectBody>,
369) -> Result<Json<ForgetSubjectResponse>, AppError> {
370    let strategy = match body.strategy.as_deref().unwrap_or("redact") {
371        "redact" => ForgetStrategy::Redact,
372        "hard_delete" => ForgetStrategy::HardDelete,
373        "soft_delete" => ForgetStrategy::SoftDelete,
374        other => {
375            return Err(AppError(CoreError::Validation(format!(
376                "invalid forget_subject strategy '{}': expected one of: redact, hard_delete, soft_delete",
377                other
378            ))));
379        }
380    };
381
382    let request = ForgetSubjectRequest {
383        subject_id: body.subject_id,
384        agent_id: body.agent_id,
385        strategy,
386    };
387
388    let response = engine.forget_subject(request).await?;
389    Ok(Json(response))
390}
391
392/// POST /v1/memories/:id/share
393pub async fn share_handler(
394    State(engine): State<AppState>,
395    Path(id): Path<Uuid>,
396    Json(body): Json<ShareBody>,
397) -> Result<Json<ShareResponse>, AppError> {
398    let permission = match body.permission.as_deref() {
399        Some(s) => Some(s.parse::<Permission>().map_err(|_| {
400            AppError(CoreError::Validation(format!(
401                "invalid permission '{}': expected one of: read, write, delete, share, delegate, admin",
402                s
403            )))
404        })?),
405        None => None,
406    };
407
408    let request = ShareRequest {
409        memory_id: id,
410        agent_id: body.agent_id,
411        target_agent_id: body.target_agent_id,
412        target_agent_ids: body.target_agent_ids,
413        permission,
414        expires_in_hours: body.expires_in_hours,
415    };
416
417    let response = engine.share(request).await?;
418    Ok(Json(response))
419}
420
421/// POST /v1/checkpoints
422pub async fn checkpoint_handler(
423    State(engine): State<AppState>,
424    Json(request): Json<CheckpointRequest>,
425) -> Result<Json<CheckpointResponse>, AppError> {
426    let response = engine.checkpoint(request).await?;
427    Ok(Json(response))
428}
429
430/// POST /v1/consolidate
431pub async fn consolidate_handler(
432    State(engine): State<AppState>,
433    Json(request): Json<ConsolidateRequest>,
434) -> Result<Json<ConsolidateResponse>, AppError> {
435    let response = engine.consolidate(request).await?;
436    Ok(Json(response))
437}
438
439/// POST /v1/branches
440pub async fn branch_handler(
441    State(engine): State<AppState>,
442    Json(request): Json<BranchRequest>,
443) -> Result<Json<BranchResponse>, AppError> {
444    let response = engine.branch(request).await?;
445    Ok(Json(response))
446}
447
448/// POST /v1/merge
449pub async fn merge_handler(
450    State(engine): State<AppState>,
451    Json(request): Json<MergeRequest>,
452) -> Result<Json<MergeResponse>, AppError> {
453    let response = engine.merge(request).await?;
454    Ok(Json(response))
455}
456
457/// POST /v1/replay
458pub async fn replay_handler(
459    State(engine): State<AppState>,
460    Json(request): Json<ReplayRequest>,
461) -> Result<Json<ReplayResponse>, AppError> {
462    let response = engine.replay(request).await?;
463    Ok(Json(response))
464}
465
466/// POST /v1/verify -- verify hash chain integrity.
467pub async fn verify_handler(
468    State(engine): State<AppState>,
469    Json(body): Json<VerifyBody>,
470) -> Result<Json<serde_json::Value>, AppError> {
471    let result = engine
472        .verify_integrity(body.agent_id, body.thread_id.as_deref())
473        .await?;
474
475    let response = serde_json::json!({
476        "valid": result.valid,
477        "total_records": result.total_records,
478        "verified_records": result.verified_records,
479        "first_broken_at": result.first_broken_at.map(|id| id.to_string()),
480        "error_message": result.error_message,
481        "status": if result.valid { "verified" } else { "integrity_violation" },
482    });
483
484    Ok(Json(response))
485}
486
487/// POST /v1/compliance/trajectory_audit -- GEM-aligned trajectory audit.
488///
489/// Anchor: arXiv:2605.26252. Complements `/v1/verify` (per-record chain
490/// integrity) on the orthogonal trajectory-correctness axis.
491pub async fn trajectory_audit_handler(
492    State(engine): State<AppState>,
493    Json(body): Json<TrajectoryAuditBody>,
494) -> Result<Json<serde_json::Value>, AppError> {
495    let agent_id = body
496        .agent_id
497        .clone()
498        .unwrap_or_else(|| engine.default_agent_id.clone());
499
500    // Mirror verify_handler's storage fetch shape: list_events returns
501    // DESC order; the trajectory audit needs chronological order.
502    let mut events = engine
503        .storage
504        .list_events(&agent_id, mnemo_core::query::MAX_BATCH_QUERY_LIMIT, 0)
505        .await?;
506    events.reverse();
507
508    let mut req = mnemo_compliance::trajectory::TrajectoryAuditRequest {
509        agent_id: Some(agent_id),
510        thread_id: body.thread_id.clone(),
511        ..Default::default()
512    };
513    if let Some(c) = body.active_bank_ceiling {
514        req.active_bank_ceiling = c;
515    }
516    if let Some(k) = body.fact_key {
517        req.fact_key = k;
518    }
519    if let Some(s) = body.named_forget_strategies {
520        req.named_forget_strategies = s;
521    }
522
523    let report = mnemo_compliance::trajectory::trajectory_audit(&events, &req)
524        .map_err(|e| AppError(CoreError::Validation(e.to_string())))?;
525
526    let response = serde_json::json!({
527        "report": report,
528        "all_ok": report.all_ok(),
529    });
530
531    Ok(Json(response))
532}
533
534/// POST /v1/delegate -- delegate permissions to another agent.
535///
536/// The caller must provide their `agent_id` and must have `Delegate`
537/// permission on the target memories. Without a full auth middleware
538/// this is advisory; production deployments should add an auth layer.
539pub async fn delegate_handler(
540    State(engine): State<AppState>,
541    Json(body): Json<DelegateRequest>,
542) -> Result<Json<serde_json::Value>, AppError> {
543    let permission: Permission = body
544        .permission
545        .parse()
546        .map_err(|e: CoreError| AppError(e))?;
547
548    let caller_agent_id = body
549        .agent_id
550        .unwrap_or_else(|| engine.default_agent_id.clone());
551
552    let scope = if let Some(ref ids) = body.memory_ids {
553        let parsed: std::result::Result<Vec<Uuid>, _> =
554            ids.iter().map(|s| Uuid::parse_str(s)).collect();
555        match parsed {
556            Ok(uuids) => {
557                // Verify caller has Delegate permission on each memory
558                for mid in &uuids {
559                    let has_perm = engine
560                        .storage
561                        .check_permission(*mid, &caller_agent_id, Permission::Delegate)
562                        .await?;
563                    if !has_perm {
564                        return Err(AppError(CoreError::PermissionDenied(format!(
565                            "agent '{}' lacks delegate permission on memory {}",
566                            caller_agent_id, mid
567                        ))));
568                    }
569                }
570                DelegationScope::ByMemoryId(uuids)
571            }
572            Err(e) => {
573                return Err(AppError(CoreError::Validation(format!(
574                    "invalid UUID in memory_ids: {e}"
575                ))));
576            }
577        }
578    } else if let Some(ref tags) = body.tags {
579        DelegationScope::ByTag(tags.clone())
580    } else {
581        DelegationScope::AllMemories
582    };
583
584    let now = chrono::Utc::now();
585    let expires_at = body
586        .expires_in_hours
587        .map(|h| (now + chrono::Duration::seconds((h * 3600.0) as i64)).to_rfc3339());
588
589    let delegation = Delegation {
590        id: Uuid::now_v7(),
591        delegator_id: caller_agent_id,
592        delegate_id: body.delegate_id.clone(),
593        permission,
594        scope,
595        max_depth: body.max_depth.unwrap_or(0),
596        current_depth: 0,
597        parent_delegation_id: None,
598        created_at: now.to_rfc3339(),
599        expires_at,
600        revoked_at: None,
601    };
602
603    engine.storage.insert_delegation(&delegation).await?;
604
605    let response = serde_json::json!({
606        "delegation_id": delegation.id.to_string(),
607        "delegator": delegation.delegator_id,
608        "delegate": delegation.delegate_id,
609        "permission": delegation.permission.to_string(),
610        "status": "delegated",
611    });
612
613    Ok(Json(response))
614}
615
616/// GET /v1/health
617pub async fn health_handler() -> Json<serde_json::Value> {
618    Json(serde_json::json!({"status": "ok"}))
619}
620
621// ---------------------------------------------------------------------------
622// Write provenance + FORGET BY PROVENANCE
623// ---------------------------------------------------------------------------
624
625fn provenance_limit(limit: Option<usize>) -> usize {
626    // Default generous, hard-capped so a single call cannot fetch unboundedly.
627    limit.unwrap_or(1000).min(10_000)
628}
629
630/// GET /v1/memories/:id/provenance — who wrote this memory, under what authority.
631/// 404 if the memory has no recorded provenance (e.g. written before provenance
632/// existed, or by a backend that does not record it).
633pub async fn get_provenance_handler(
634    State(engine): State<AppState>,
635    Path(id): Path<Uuid>,
636) -> Result<Json<WriteProvenance>, AppError> {
637    let prov = engine
638        .write_provenance_for(id)
639        .await?
640        .ok_or_else(|| CoreError::NotFound(format!("no write provenance for memory {id}")))?;
641    Ok(Json(prov))
642}
643
644#[derive(Debug, Deserialize)]
645pub struct ProvenanceListParams {
646    pub limit: Option<usize>,
647}
648
649/// GET /v1/provenance/principal/:principal — everything a principal wrote.
650pub async fn provenance_by_principal_handler(
651    State(engine): State<AppState>,
652    Path(principal): Path<String>,
653    Query(params): Query<ProvenanceListParams>,
654) -> Result<Json<Vec<WriteProvenance>>, AppError> {
655    let writes = engine
656        .writes_by_principal(&principal, provenance_limit(params.limit))
657        .await?;
658    Ok(Json(writes))
659}
660
661/// GET /v1/provenance/session/:session_id — everything written under a session/trace.
662pub async fn provenance_by_session_handler(
663    State(engine): State<AppState>,
664    Path(session_id): Path<String>,
665    Query(params): Query<ProvenanceListParams>,
666) -> Result<Json<Vec<WriteProvenance>>, AppError> {
667    let writes = engine
668        .writes_by_session(&session_id, provenance_limit(params.limit))
669        .await?;
670    Ok(Json(writes))
671}
672
673/// GET /v1/provenance/verify?limit=N — tamper-evidence over the write-provenance
674/// chain (append history). Returns the chain verification result.
675pub async fn verify_provenance_handler(
676    State(engine): State<AppState>,
677    Query(params): Query<ProvenanceListParams>,
678) -> Result<Json<mnemo_core::hash::ChainVerificationResult>, AppError> {
679    let result = engine
680        .verify_provenance_chain(provenance_limit(params.limit))
681        .await?;
682    Ok(Json(result))
683}
684
685#[derive(Debug, Deserialize)]
686pub struct ForgetByProvenanceBody {
687    /// Revoke everything this principal wrote. Exactly one of `principal` or
688    /// `session_id` must be set.
689    pub principal: Option<String>,
690    /// Revoke everything written under this session / trace id.
691    pub session_id: Option<String>,
692    /// soft_delete (default) | hard_delete | redact.
693    pub strategy: Option<String>,
694}
695
696/// POST /v1/provenance/forget — FORGET BY PROVENANCE.
697///
698/// Revoke every memory a principal (or session) authored, in one call. This is
699/// remediation targeted at the responsible writer — not an indiscriminate wipe.
700pub async fn forget_by_provenance_handler(
701    State(engine): State<AppState>,
702    Json(body): Json<ForgetByProvenanceBody>,
703) -> Result<Json<ForgetResponse>, AppError> {
704    let strategy = match body.strategy.as_deref().unwrap_or("soft_delete") {
705        "soft_delete" => ForgetStrategy::SoftDelete,
706        "hard_delete" => ForgetStrategy::HardDelete,
707        "redact" => ForgetStrategy::Redact,
708        other => {
709            return Err(AppError(CoreError::Validation(format!(
710                "invalid forget strategy '{other}': expected one of: soft_delete, hard_delete, redact"
711            ))));
712        }
713    };
714
715    let response = match (body.principal, body.session_id) {
716        (Some(p), None) => engine.forget_by_principal(&p, strategy).await?,
717        (None, Some(s)) => engine.forget_by_session(&s, strategy).await?,
718        (Some(_), Some(_)) => {
719            return Err(AppError(CoreError::Validation(
720                "provide exactly one of 'principal' or 'session_id', not both".to_string(),
721            )));
722        }
723        (None, None) => {
724            return Err(AppError(CoreError::Validation(
725                "provide one of 'principal' or 'session_id'".to_string(),
726            )));
727        }
728    };
729    Ok(Json(response))
730}
731
732// ---------------------------------------------------------------------------
733// GenAI semantic convention helpers
734// ---------------------------------------------------------------------------
735
736struct GenAiFields {
737    event_type: EventType,
738    model: Option<String>,
739    tokens_input: Option<i64>,
740    tokens_output: Option<i64>,
741    cost_usd: Option<f64>,
742}
743
744/// Extract GenAI semantic convention fields from OTLP span attributes.
745/// See: <https://opentelemetry.io/docs/specs/semconv/gen-ai/>
746fn extract_genai_fields(span: &serde_json::Value) -> GenAiFields {
747    let attributes = span.get("attributes").and_then(|v| v.as_array());
748
749    let mut model = None;
750    let mut tokens_input = None;
751    let mut tokens_output = None;
752    let mut cost_usd = None;
753    let mut operation_name = None;
754
755    if let Some(attrs) = attributes {
756        for attr in attrs {
757            let key = match attr.get("key").and_then(|k| k.as_str()) {
758                Some(k) => k,
759                None => continue,
760            };
761            let value = attr.get("value");
762
763            match key {
764                "gen_ai.request.model" => {
765                    model = value
766                        .and_then(|v| v.get("stringValue"))
767                        .and_then(|v| v.as_str())
768                        .map(|s| s.to_string());
769                }
770                "gen_ai.usage.input_tokens" => {
771                    tokens_input = value.and_then(|v| v.get("intValue")).and_then(|v| {
772                        v.as_str()
773                            .and_then(|s| s.parse::<i64>().ok())
774                            .or_else(|| v.as_i64())
775                    });
776                }
777                "gen_ai.usage.output_tokens" => {
778                    tokens_output = value.and_then(|v| v.get("intValue")).and_then(|v| {
779                        v.as_str()
780                            .and_then(|s| s.parse::<i64>().ok())
781                            .or_else(|| v.as_i64())
782                    });
783                }
784                "gen_ai.usage.cost" => {
785                    cost_usd = value
786                        .and_then(|v| v.get("doubleValue"))
787                        .and_then(|v| v.as_f64());
788                }
789                "gen_ai.operation.name" => {
790                    operation_name = value
791                        .and_then(|v| v.get("stringValue"))
792                        .and_then(|v| v.as_str())
793                        .map(|s| s.to_string());
794                }
795                _ => {}
796            }
797        }
798    }
799
800    // If no operation_name from attributes, fall back to span name.
801    let op = operation_name.or_else(|| {
802        span.get("name")
803            .and_then(|v| v.as_str())
804            .map(|s| s.to_string())
805    });
806
807    // Map operation name to EventType.
808    let event_type = match op.as_deref() {
809        Some(s) if s.contains("chat") => EventType::AssistantMessage,
810        Some(s) if s.contains("embed") => EventType::RetrievalQuery,
811        Some(s) if s.contains("tool") => EventType::ToolCall,
812        _ => EventType::ToolCall, // default
813    };
814
815    GenAiFields {
816        event_type,
817        model,
818        tokens_input,
819        tokens_output,
820        cost_usd,
821    }
822}
823
824/// POST /v1/ingest/otlp -- ingest simplified OTLP JSON spans as agent events.
825pub async fn otlp_ingest_handler(
826    State(engine): State<AppState>,
827    Json(body): Json<serde_json::Value>,
828) -> Result<Json<serde_json::Value>, AppError> {
829    let resource_spans = body
830        .get("resourceSpans")
831        .and_then(|v| v.as_array())
832        .cloned()
833        .unwrap_or_default();
834
835    let mut count: u64 = 0;
836
837    for rs in &resource_spans {
838        // Extract agent_id from resource attributes (service.name or agent.id).
839        let resource_agent_id = rs
840            .get("resource")
841            .and_then(|r| r.get("attributes"))
842            .and_then(|attrs| attrs.as_array())
843            .and_then(|attrs| {
844                attrs.iter().find_map(|attr| {
845                    let key = attr.get("key")?.as_str()?;
846                    if key == "agent.id" || key == "service.name" {
847                        attr.get("value")
848                            .and_then(|v| v.get("stringValue"))
849                            .and_then(|v| v.as_str())
850                            .map(|s| s.to_string())
851                    } else {
852                        None
853                    }
854                })
855            });
856
857        let scope_spans = rs
858            .get("scopeSpans")
859            .and_then(|v| v.as_array())
860            .cloned()
861            .unwrap_or_default();
862
863        for ss in &scope_spans {
864            let spans = ss
865                .get("spans")
866                .and_then(|v| v.as_array())
867                .cloned()
868                .unwrap_or_default();
869
870            for span in &spans {
871                let trace_id = span
872                    .get("traceId")
873                    .and_then(|v| v.as_str())
874                    .map(|s| s.to_string());
875
876                let span_id = span
877                    .get("spanId")
878                    .and_then(|v| v.as_str())
879                    .map(|s| s.to_string());
880
881                let agent_id = resource_agent_id
882                    .clone()
883                    .unwrap_or_else(|| engine.default_agent_id.clone());
884
885                // Compute latency from start/end nanosecond timestamps.
886                // OTLP encodes nanos as either JSON strings or integers.
887                let start_nano: u64 = span
888                    .get("startTimeUnixNano")
889                    .and_then(|v| {
890                        v.as_str()
891                            .and_then(|s| s.parse::<u64>().ok())
892                            .or_else(|| v.as_u64())
893                    })
894                    .unwrap_or(0);
895
896                let end_nano: u64 = span
897                    .get("endTimeUnixNano")
898                    .and_then(|v| {
899                        v.as_str()
900                            .and_then(|s| s.parse::<u64>().ok())
901                            .or_else(|| v.as_u64())
902                    })
903                    .unwrap_or(0);
904
905                let latency_ms = if end_nano > start_nano {
906                    Some(((end_nano - start_nano) / 1_000_000) as i64)
907                } else {
908                    None
909                };
910
911                // Convert startTimeUnixNano to RFC3339 timestamp.
912                let timestamp = if start_nano > 0 {
913                    let secs = (start_nano / 1_000_000_000) as i64;
914                    let nsecs = (start_nano % 1_000_000_000) as u32;
915                    chrono::DateTime::from_timestamp(secs, nsecs)
916                        .map(|dt| dt.to_rfc3339())
917                        .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
918                } else {
919                    chrono::Utc::now().to_rfc3339()
920                };
921
922                // Collect span attributes as the event payload.
923                let payload = span
924                    .get("attributes")
925                    .cloned()
926                    .unwrap_or(serde_json::json!({}));
927
928                let genai = extract_genai_fields(span);
929
930                let content_hash =
931                    compute_content_hash(&payload.to_string(), &agent_id, &timestamp);
932
933                let event = AgentEvent {
934                    id: Uuid::now_v7(),
935                    agent_id,
936                    thread_id: None,
937                    run_id: None,
938                    parent_event_id: None,
939                    event_type: genai.event_type,
940                    payload,
941                    trace_id,
942                    span_id,
943                    model: genai.model,
944                    tokens_input: genai.tokens_input,
945                    tokens_output: genai.tokens_output,
946                    latency_ms,
947                    cost_usd: genai.cost_usd,
948                    timestamp,
949                    logical_clock: 0,
950                    content_hash,
951                    prev_hash: None,
952                    embedding: None,
953                };
954
955                engine.storage.insert_event(&event).await?;
956                count += 1;
957            }
958        }
959    }
960
961    Ok(Json(serde_json::json!({"accepted": count})))
962}
963
964// ---------------------------------------------------------------------------
965// Tests
966// ---------------------------------------------------------------------------
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    #[test]
973    fn test_extract_genai_fields_chat_span() {
974        let span = serde_json::json!({
975            "name": "chat gpt-4",
976            "attributes": [
977                {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4"}},
978                {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "150"}},
979                {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "50"}},
980                {"key": "gen_ai.usage.cost", "value": {"doubleValue": 0.006}},
981                {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}
982            ]
983        });
984        let fields = extract_genai_fields(&span);
985        assert_eq!(fields.event_type, EventType::AssistantMessage);
986        assert_eq!(fields.model.as_deref(), Some("gpt-4"));
987        assert_eq!(fields.tokens_input, Some(150));
988        assert_eq!(fields.tokens_output, Some(50));
989        assert!((fields.cost_usd.unwrap() - 0.006).abs() < 1e-9);
990    }
991
992    #[test]
993    fn test_extract_genai_fields_non_genai_default() {
994        let span = serde_json::json!({
995            "name": "http.request",
996            "attributes": [
997                {"key": "http.method", "value": {"stringValue": "GET"}}
998            ]
999        });
1000        let fields = extract_genai_fields(&span);
1001        assert_eq!(fields.event_type, EventType::ToolCall);
1002        assert!(fields.model.is_none());
1003        assert!(fields.tokens_input.is_none());
1004    }
1005}