Skip to main content

ijima_server/
api.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP/JSON API surface — the REST endpoints harnesses speak.
5//!
6//! Maps the core [`Store`] trait methods onto axum routes, each guarded
7//! by a Schubert capability check via [`AuthPrincipal`]. Every request
8//! is scoped to the authenticated principal's personal namespace
9//! (`ns_<principal>_private`); shared/global namespaces land with the
10//! `memory_promote` endpoint.
11//!
12//! ## Routes
13//!
14//! | Method | Path | Capability | Store method |
15//! |---|---|---|---|
16//! | GET | `/health` | (none) | — |
17//! | POST | `/memories` | `memory:write` | `store_memory` |
18//! | GET | `/memories/:id` | `memory:read` | `recall_memory` |
19//! | DELETE | `/memories/:id` | `memory:write` | `delete_memory` |
20//! | POST | `/memories/search` | `memory:read` | `search_memories` |
21//! | POST | `/sessions/:session_id/turns` | `session:ingest` | `ingest_turn` |
22//! | GET | `/sessions/:session_id/turns` | `memory:read` | `session_turns` |
23//! | POST | `/sessions` | `session:ingest` | `create_session` |
24//! | GET | `/sessions` | `memory:read` | `list_sessions` |
25//! | POST | `/sessions/:session_id/end` | `session:ingest` | `end_session` |
26//! | GET | `/mining/queue` | `mining:review` | `list_pending` |
27//! | POST | `/mining/queue/:id/accept` | `mining:review` | `accept_extraction` |
28//! | POST | `/mining/queue/:id/reject` | `mining:review` | `reject_extraction` |
29//! | POST | `/sessions/:session_id/mine` | `mining:trigger` | `trigger_mine` (feature `mining`) |
30
31use std::sync::Arc;
32
33use axum::{
34    Extension, Json, Router,
35    extract::{Path, Query},
36    http::StatusCode,
37    response::{IntoResponse, Response},
38    routing::{get, post},
39};
40use serde::{Deserialize, Serialize};
41
42#[cfg(feature = "mining")]
43use ijima_core::capabilities::MINING_TRIGGER;
44use ijima_core::{
45    AcceptedExtraction, DiaryEntry, Embedder, EntityId, KnowledgeGraph, Memory, MemoryId,
46    NamespaceCount, NamespaceId, PalaceGraph, ProjectTaxon, QueuedExtraction, RepoDirectory, Room,
47    SearchHit, Session, SessionId, SessionTurn, Store, TunnelTraversal,
48    capabilities::{
49        ADMIN, KNOWLEDGE_READ, MEMORY_READ, MEMORY_WRITE, MINING_REVIEW, SESSION_INGEST,
50        TRUST_PROMOTE,
51    },
52    harness::Harness,
53};
54
55use crate::extractor::AuthPrincipal;
56use crate::redaction::Redactor;
57
58/// Builds the Ijima HTTP application router.
59///
60/// `auth` and `store` are shared via axum's [`Extension`] layer; the
61/// [`AuthPrincipal`] extractor reads `auth` to verify bearer tokens.
62pub fn app(
63    auth: Arc<crate::IjimaAuth>,
64    store: Arc<dyn Store>,
65    kg: Arc<dyn KnowledgeGraph>,
66    embedder: Option<Arc<dyn Embedder>>,
67    redactor: Arc<Redactor>,
68    #[cfg(feature = "rate-limit")] rate_limiter: Option<crate::rate_limit::RateLimitState>,
69) -> Router {
70    let router = Router::new()
71        .route("/health", get(health))
72        .route("/status", get(status))
73        .route("/memories", get(browse_memories).post(store_memory))
74        .route("/memories/check", post(check_duplicate))
75        .route("/memories/search", post(search_memories))
76        .route("/memories/stats", get(memory_stats))
77        .route("/memories/{id}", get(recall_memory).delete(delete_memory))
78        .route("/memories/{id}/promote", post(promote_memory))
79        .route("/rooms", get(list_rooms))
80        .route("/taxonomy", get(taxonomy))
81        .route("/palace/graph", get(palace_graph))
82        .route("/palace/tunnel", get(traverse_tunnel))
83        .route("/diaries", post(write_diary))
84        .route("/diaries/{agent}", get(read_diary))
85        .route("/repos", get(list_repos).post(register_repo))
86        .route("/repos/resolve", get(resolve_repo))
87        .route("/doctrine", post(ingest_doctrine))
88        .route("/wakeup", get(wakeup))
89        .route("/kg/triples", post(add_triple).get(find_triples))
90        .route("/kg/entities/{id}", get(query_entity))
91        .route("/kg/triples/{id}/invalidate", post(invalidate_triple))
92        .route("/kg/timeline", get(kg_timeline))
93        .route("/kg/stats", get(kg_stats))
94        .route(
95            "/sessions/{session_id}/turns",
96            post(ingest_turn).get(session_turns),
97        )
98        .route("/sessions", post(create_session).get(list_sessions))
99        .route("/sessions/{session_id}/end", post(end_session))
100        .route("/mining/queue", get(list_pending))
101        .route("/mining/queue/{id}/accept", post(accept_extraction))
102        .route("/mining/queue/{id}/reject", post(reject_extraction));
103    #[cfg(feature = "mining")]
104    let router = router.route("/sessions/{session_id}/mine", post(trigger_mine));
105    let router = router
106        .layer(Extension(auth))
107        .layer(Extension(store))
108        .layer(Extension(kg))
109        .layer(Extension(embedder))
110        .layer(Extension(redactor));
111
112    #[cfg(feature = "rate-limit")]
113    let router = match rate_limiter {
114        Some(rl) => router.layer(Extension(rl)),
115        None => router,
116    };
117    #[cfg(not(feature = "rate-limit"))]
118    let router = router;
119
120    router
121}
122
123// ---------- errors ----------
124
125/// API-level error mapping to HTTP status codes.
126#[derive(Debug)]
127pub enum ApiError {
128    /// Capability check failed (principal's token lacks the required cap).
129    Forbidden,
130    /// Resource absent (or in a different namespace).
131    NotFound,
132    /// Malformed request body or parameters.
133    BadRequest(String),
134    /// Duplicate content (content-hash dedup) — 409.
135    Conflict(String),
136    /// Store / internal failure.
137    Internal(String),
138}
139
140impl IntoResponse for ApiError {
141    fn into_response(self) -> Response {
142        let (status, msg): (StatusCode, String) = match self {
143            ApiError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".into()),
144            ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
145            ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
146            ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
147            ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
148        };
149        (status, msg).into_response()
150    }
151}
152
153fn internal(e: ijima_core::IjimaError) -> ApiError {
154    match e {
155        ijima_core::IjimaError::Duplicate { detail } => ApiError::Conflict(detail),
156        other => ApiError::Internal(other.to_string()),
157    }
158}
159
160/// Query params carrying an optional namespace override + limit.
161#[derive(Deserialize, Default)]
162struct NsQuery {
163    /// Override the default personal namespace. Personal namespaces
164    /// (`ns_<name>_private`) belonging to *other* principals are
165    /// rejected with 403; shared/global namespaces are allowed.
166    namespace: Option<String>,
167    limit: Option<usize>,
168}
169
170/// Resolves the effective namespace for a request: the caller's
171/// personal namespace by default, or the requested one if authorized.
172///
173/// Authorization (v0, naming-convention based):
174/// - `ns_<this_principal>_private` → allowed (own personal).
175/// - any other `ns_*_private` → **403** (someone else's personal).
176/// - anything else → allowed (shared / global).
177fn resolve_ns(
178    principal: &AuthPrincipal,
179    requested: Option<&str>,
180) -> Result<ijima_core::NamespaceId, ApiError> {
181    let own = format!("ns_{}_private", principal.0.principal.as_str());
182    match requested {
183        None => Ok(ijima_core::NamespaceId::new(own)),
184        Some(ns) if ns == own => Ok(ijima_core::NamespaceId::new(ns)),
185        Some(ns) if ns.ends_with("_private") => Err(ApiError::Forbidden),
186        Some(ns) => Ok(ijima_core::NamespaceId::new(ns)),
187    }
188}
189
190// ---------- handlers ----------
191
192async fn health() -> impl IntoResponse {
193    Json(serde_json::json!({ "status": "ok" }))
194}
195
196#[derive(Serialize)]
197struct StatusResponse {
198    memories: usize,
199    namespaces: Vec<NamespaceCount>,
200    entities: usize,
201    triples: usize,
202}
203
204/// Global store statistics across all namespaces. Admin-gated (it spans
205/// every principal's data). Per-namespace KG counts are available via
206/// `GET /kg/stats?namespace=...`.
207async fn status(
208    principal: AuthPrincipal,
209    Extension(store): Extension<Arc<dyn Store>>,
210    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
211) -> Result<Json<StatusResponse>, ApiError> {
212    if !principal.0.may(ijima_core::capabilities::ADMIN) {
213        return Err(ApiError::Forbidden);
214    }
215    let store_stats = store.store_stats().await.map_err(internal)?;
216    let kg_stats = kg.kg_global_stats().await.map_err(internal)?;
217    Ok(Json(StatusResponse {
218        memories: store_stats.total_memories,
219        namespaces: store_stats.namespaces,
220        entities: kg_stats.entities,
221        triples: kg_stats.triples,
222    }))
223}
224
225#[derive(Serialize)]
226struct IdResponse {
227    id: String,
228}
229
230async fn store_memory(
231    principal: AuthPrincipal,
232    Extension(store): Extension<Arc<dyn Store>>,
233    Json(memory): Json<Memory>,
234) -> Result<Json<IdResponse>, ApiError> {
235    if !principal.0.may(MEMORY_WRITE) {
236        return Err(ApiError::Forbidden);
237    }
238    let ns = principal.0.personal_namespace();
239    let mut memory = memory;
240    if memory.created_at.is_empty() {
241        memory.created_at = std::time::SystemTime::now()
242            .duration_since(std::time::UNIX_EPOCH)
243            .map(|d| d.as_secs().to_string())
244            .unwrap_or_default();
245    }
246    let id = store.store_memory(&ns, memory).await.map_err(internal)?;
247    Ok(Json(IdResponse { id: id.0 }))
248}
249
250#[derive(Deserialize)]
251struct CheckDuplicateRequest {
252    content: String,
253}
254
255#[derive(Serialize)]
256struct CheckDuplicateResponse {
257    /// The id of an existing memory with identical content, if any.
258    duplicate: Option<String>,
259}
260
261/// Pre-check for content-hash dedup (`POST /memories/check`). Returns
262/// the existing memory id if identical content is already stored in the
263/// caller's (effective) namespace.
264async fn check_duplicate(
265    principal: AuthPrincipal,
266    Extension(store): Extension<Arc<dyn Store>>,
267    Query(q): Query<NsQuery>,
268    Json(req): Json<CheckDuplicateRequest>,
269) -> Result<Json<CheckDuplicateResponse>, ApiError> {
270    if !principal.0.may(MEMORY_READ) {
271        return Err(ApiError::Forbidden);
272    }
273    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
274    let dup = store
275        .check_duplicate(&ns, &req.content)
276        .await
277        .map_err(internal)?;
278    Ok(Json(CheckDuplicateResponse {
279        duplicate: dup.map(|id| id.0),
280    }))
281}
282
283async fn recall_memory(
284    principal: AuthPrincipal,
285    Extension(store): Extension<Arc<dyn Store>>,
286    Path(id): Path<String>,
287    Query(q): Query<NsQuery>,
288) -> Result<Json<Memory>, ApiError> {
289    if !principal.0.may(MEMORY_READ) {
290        return Err(ApiError::Forbidden);
291    }
292    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
293    match store
294        .recall_memory(&ns, &MemoryId(id))
295        .await
296        .map_err(internal)?
297    {
298        Some(memory) => Ok(Json(memory)),
299        None => Err(ApiError::NotFound),
300    }
301}
302
303async fn delete_memory(
304    principal: AuthPrincipal,
305    Extension(store): Extension<Arc<dyn Store>>,
306    Path(id): Path<String>,
307    Query(q): Query<NsQuery>,
308) -> Result<StatusCode, ApiError> {
309    if !principal.0.may(MEMORY_WRITE) {
310        return Err(ApiError::Forbidden);
311    }
312    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
313    store
314        .delete_memory(&ns, &MemoryId(id))
315        .await
316        .map_err(internal)?;
317    Ok(StatusCode::NO_CONTENT)
318}
319
320#[derive(Deserialize)]
321struct SearchRequest {
322    /// The query text. The daemon embeds this centrally with its own
323    /// embedder (D9 §5: "the service owns the model"), guaranteeing
324    /// vector compatibility with stored memories.
325    text: String,
326    limit: Option<usize>,
327    /// Search scope: `personal` (default — the resolved namespace only) or
328    /// `visible` (the principal's private namespace + the `global` commons,
329    /// merged by similarity). The pi integration uses `visible` for parity
330    /// with pi-mempalace's global search.
331    scope: Option<String>,
332}
333
334#[derive(Serialize)]
335struct SearchResponse {
336    memories: Vec<SearchHit>,
337}
338
339async fn search_memories(
340    principal: AuthPrincipal,
341    Extension(store): Extension<Arc<dyn Store>>,
342    Extension(embedder): Extension<Option<Arc<dyn Embedder>>>,
343    Query(q): Query<NsQuery>,
344    Json(req): Json<SearchRequest>,
345) -> Result<Json<SearchResponse>, ApiError> {
346    if !principal.0.may(MEMORY_READ) {
347        return Err(ApiError::Forbidden);
348    }
349    let embedder = embedder
350        .ok_or_else(|| ApiError::Internal("search unavailable: daemon has no embedder".into()))?;
351    let query = embedder.embed(&req.text).map_err(internal)?;
352    let limit = req.limit.unwrap_or(10);
353
354    // `visible` scope: merge the principal's private namespace + the global
355    // commons, ranked by similarity across both (pi-mempalace parity). The
356    // `personal` default searches only the resolved namespace.
357    let hits = if req.scope.as_deref() == Some("visible") {
358        let own_ns = principal.0.personal_namespace();
359        let global_ns = NamespaceId::new("global");
360        let own_hits = store
361            .search_memories(&own_ns, &query, limit)
362            .await
363            .map_err(internal)?;
364        let global_hits = if own_ns == global_ns {
365            Vec::new()
366        } else {
367            store
368                .search_memories(&global_ns, &query, limit)
369                .await
370                .map_err(internal)?
371        };
372        merge_search_hits(own_hits, global_hits, limit)
373    } else {
374        let ns = resolve_ns(&principal, q.namespace.as_deref())?;
375        store
376            .search_memories(&ns, &query, limit)
377            .await
378            .map_err(internal)?
379    };
380    Ok(Json(SearchResponse { memories: hits }))
381}
382
383/// Merges two scored hit lists by similarity (desc), deduplicating by memory
384/// id (the highest-similarity instance wins — NOT `dedup_by`, which only
385/// drops adjacent dups) and truncating to `limit`. Pure — the `scope=visible`
386/// path uses this to combine private + global results.
387fn merge_search_hits(a: Vec<SearchHit>, b: Vec<SearchHit>, limit: usize) -> Vec<SearchHit> {
388    use std::collections::HashSet;
389    let mut all: Vec<SearchHit> = a.into_iter().chain(b).collect();
390    all.sort_by(|x, y| {
391        y.similarity
392            .partial_cmp(&x.similarity)
393            .unwrap_or(std::cmp::Ordering::Equal)
394    });
395    // Keep the first (highest-similarity, post-sort) instance of each id.
396    let mut seen: HashSet<String> = HashSet::new();
397    all.retain(|h| seen.insert(h.memory.id.0.clone()));
398    all.truncate(limit);
399    all
400}
401
402// ---------- promotion (personal → shared, D9 §2) ----------
403
404#[derive(Deserialize)]
405struct PromoteRequest {
406    /// The shared/team namespace to promote into
407    /// (e.g. `ns_team_default`).
408    target_namespace: String,
409    /// Optional id for the promoted copy. Defaults to
410    /// `<original_id>__shared`.
411    new_id: Option<String>,
412}
413
414#[derive(Serialize)]
415struct PromoteResponse {
416    id: String,
417    original_id: String,
418    target_namespace: String,
419    redactions: Vec<crate::redaction::Redaction>,
420}
421
422/// Promotes a memory from the caller's personal namespace to a shared
423/// namespace, running the [redaction filter](crate::redaction) at the
424/// boundary. The original stays verbatim in personal scope; a scrubbed
425/// copy lands in the target namespace.
426async fn promote_memory(
427    principal: AuthPrincipal,
428    Extension(store): Extension<Arc<dyn Store>>,
429    Extension(redactor): Extension<Arc<Redactor>>,
430    Path(id): Path<String>,
431    Json(req): Json<PromoteRequest>,
432) -> Result<Json<PromoteResponse>, ApiError> {
433    if !principal.0.may(TRUST_PROMOTE) {
434        return Err(ApiError::Forbidden);
435    }
436    let personal_ns = principal.0.personal_namespace();
437
438    // Read from the caller's personal namespace.
439    let memory = store
440        .recall_memory(&personal_ns, &MemoryId(id.clone()))
441        .await
442        .map_err(internal)?
443        .ok_or(ApiError::NotFound)?;
444
445    // Scrub at the boundary (D9 §2 — the one place filtering happens).
446    let scrubbed = redactor.redact(&memory.content);
447
448    // Write the redacted copy to the shared namespace.
449    let new_id = req
450        .new_id
451        .clone()
452        .unwrap_or_else(|| format!("{id}__shared"));
453    let promoted = Memory {
454        id: MemoryId(new_id.clone()),
455        content: scrubbed.text,
456        project: memory.project,
457        topic: memory.topic,
458        source: ijima_core::memory::MemorySource::Explicit,
459        harness: memory.harness,
460        // Provenance back-reference to the original personal memory.
461        session_id: Some(id.clone()),
462        // Promotion preserves the origin/authority provenance of the source.
463        origin: memory.origin.clone(),
464        authority: memory.authority.clone(),
465        importance: memory.importance,
466        created_at: memory.created_at.clone(),
467    };
468    let target_ns = ijima_core::NamespaceId::new(&req.target_namespace);
469    store
470        .store_memory(&target_ns, promoted)
471        .await
472        .map_err(internal)?;
473
474    Ok(Json(PromoteResponse {
475        id: new_id,
476        original_id: id,
477        target_namespace: req.target_namespace,
478        redactions: scrubbed.redactions,
479    }))
480}
481
482// ---------- doctrine ingest (D9) ----------
483
484#[derive(Deserialize)]
485struct DoctrineRequest {
486    id: String,
487    content: String,
488    project: String,
489    topic: String,
490}
491
492/// Ingests a curated doctrine entry into the global `ns_doctrine`
493/// namespace. Admin-gated — doctrine is PR-reviewed in Git and never
494/// written by agents. Idempotent (delete-then-store) so re-ingests
495/// upsert cleanly. No redaction (doctrine is pre-reviewed).
496async fn ingest_doctrine(
497    principal: AuthPrincipal,
498    Extension(store): Extension<Arc<dyn Store>>,
499    Json(req): Json<DoctrineRequest>,
500) -> Result<Json<IdResponse>, ApiError> {
501    if !principal.0.may(ijima_core::capabilities::ADMIN) {
502        return Err(ApiError::Forbidden);
503    }
504    let ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
505    // Idempotent upsert: remove any existing entry, then store.
506    store
507        .delete_memory(&ns, &MemoryId(req.id.clone()))
508        .await
509        .map_err(internal)?;
510    let memory = Memory {
511        id: MemoryId(req.id.clone()),
512        content: req.content,
513        project: req.project,
514        topic: req.topic,
515        source: ijima_core::memory::MemorySource::Doctrine,
516        harness: ijima_core::harness::Harness::Other,
517        session_id: None,
518        // Doctrine is the curated local tier — authoritative on this instance.
519        origin: ijima_core::InstanceId::local(),
520        authority: ijima_core::AuthorityScope::local(),
521        importance: 1.0,
522        created_at: std::time::SystemTime::now()
523            .duration_since(std::time::UNIX_EPOCH)
524            .map(|d| d.as_secs().to_string())
525            .unwrap_or_default(),
526    };
527    store.store_memory(&ns, memory).await.map_err(internal)?;
528    Ok(Json(IdResponse { id: req.id }))
529}
530
531// ---------- wake-up composition (D9 §4) ----------
532
533/// How many personal essentials to include in a wake-up response.
534const WAKEUP_PERSONAL_LIMIT: usize = 20;
535/// How many doctrine entries to include.
536const WAKEUP_DOCTRINE_LIMIT: usize = 50;
537
538#[derive(Serialize)]
539struct WakeupResponse {
540    /// L0: the authenticated principal's identity.
541    identity: serde_json::Value,
542    /// L1a: the caller's personal essentials (top-N by importance + recency).
543    personal_essentials: Vec<Memory>,
544    /// L1b: the shared team doctrine baseline (identical across the team).
545    doctrine: Vec<Memory>,
546}
547
548/// Composes the session-start context: L0 identity + L1a personal
549/// essentials + L1b team doctrine. This is the "shared brain" — L1b is
550/// identical across the team, L1a is the individual's personal brain.
551async fn wakeup(
552    principal: AuthPrincipal,
553    Extension(store): Extension<Arc<dyn Store>>,
554) -> Result<Json<WakeupResponse>, ApiError> {
555    if !principal.0.may(MEMORY_READ) {
556        return Err(ApiError::Forbidden);
557    }
558    let personal_ns = principal.0.personal_namespace();
559    let doctrine_ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
560
561    let (personal_essentials, doctrine) = tokio::join!(
562        store.list_memories(&personal_ns, WAKEUP_PERSONAL_LIMIT),
563        store.list_memories(&doctrine_ns, WAKEUP_DOCTRINE_LIMIT),
564    );
565
566    Ok(Json(WakeupResponse {
567        identity: serde_json::json!({ "principal": principal.0.principal.as_str() }),
568        personal_essentials: personal_essentials.map_err(internal)?,
569        doctrine: doctrine.map_err(internal)?,
570    }))
571}
572
573// ---------- knowledge graph ----------
574
575#[derive(Deserialize)]
576struct AddTripleRequest {
577    subject: String,
578    predicate: String,
579    object: String,
580    valid_from: Option<String>,
581    confidence: Option<f32>,
582    source_memory_id: Option<String>,
583}
584
585async fn add_triple(
586    principal: AuthPrincipal,
587    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
588    Extension(store): Extension<Arc<dyn Store>>,
589    Json(req): Json<AddTripleRequest>,
590) -> Result<Json<ijima_core::Triple>, ApiError> {
591    if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
592        return Err(ApiError::Forbidden);
593    }
594    let ns = resolve_ns(&principal, None)?;
595    let triple = kg
596        .add_triple(
597            &ns,
598            EntityId::new(req.subject),
599            &req.predicate,
600            EntityId::new(req.object),
601            req.valid_from.as_deref(),
602            req.confidence.unwrap_or(1.0),
603            req.source_memory_id.as_deref(),
604        )
605        .await
606        .map_err(internal)?;
607    // Touch `store` so the Extension is consumed.
608    let _ = store;
609    Ok(Json(triple))
610}
611
612async fn query_entity(
613    principal: AuthPrincipal,
614    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
615    Path(id): Path<String>,
616    Query(q): Query<NsQuery>,
617) -> Result<Json<ijima_core::EntityRecord>, ApiError> {
618    if !principal.0.may(KNOWLEDGE_READ) {
619        return Err(ApiError::Forbidden);
620    }
621    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
622    let rec = kg
623        .query_entity(&ns, &EntityId::new(id))
624        .await
625        .map_err(internal)?;
626    Ok(Json(rec))
627}
628
629async fn invalidate_triple(
630    principal: AuthPrincipal,
631    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
632    Path(id): Path<String>,
633    Query(q): Query<NsQuery>,
634) -> Result<StatusCode, ApiError> {
635    if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
636        return Err(ApiError::Forbidden);
637    }
638    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
639    kg.invalidate_triple(&ns, &id).await.map_err(internal)?;
640    Ok(StatusCode::NO_CONTENT)
641}
642
643#[derive(Deserialize, Default)]
644struct FindTriplesQuery {
645    namespace: Option<String>,
646    subject: Option<String>,
647    predicate: Option<String>,
648    object: Option<String>,
649}
650
651async fn find_triples(
652    principal: AuthPrincipal,
653    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
654    Query(q): Query<FindTriplesQuery>,
655) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
656    if !principal.0.may(KNOWLEDGE_READ) {
657        return Err(ApiError::Forbidden);
658    }
659    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
660    let triples = kg
661        .find_triples(
662            &ns,
663            q.subject.as_deref().map(EntityId::new).as_ref(),
664            q.predicate.as_deref(),
665            q.object.as_deref().map(EntityId::new).as_ref(),
666        )
667        .await
668        .map_err(internal)?;
669    Ok(Json(triples))
670}
671
672async fn kg_timeline(
673    principal: AuthPrincipal,
674    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
675    Query(q): Query<NsQuery>,
676) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
677    if !principal.0.may(KNOWLEDGE_READ) {
678        return Err(ApiError::Forbidden);
679    }
680    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
681    let triples = kg
682        .kg_timeline(&ns, q.limit.unwrap_or(50))
683        .await
684        .map_err(internal)?;
685    Ok(Json(triples))
686}
687
688async fn kg_stats(
689    principal: AuthPrincipal,
690    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
691    Query(q): Query<NsQuery>,
692) -> Result<Json<ijima_core::KgStats>, ApiError> {
693    if !principal.0.may(KNOWLEDGE_READ) {
694        return Err(ApiError::Forbidden);
695    }
696    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
697    let stats = kg.knowledge_stats(&ns).await.map_err(internal)?;
698    Ok(Json(stats))
699}
700
701async fn ingest_turn(
702    principal: AuthPrincipal,
703    Extension(store): Extension<Arc<dyn Store>>,
704    Path(session_id): Path<String>,
705    Json(mut turn): Json<SessionTurn>,
706) -> Result<StatusCode, ApiError> {
707    if !principal.0.may(SESSION_INGEST) {
708        return Err(ApiError::Forbidden);
709    }
710    let ns = principal.0.personal_namespace();
711    turn.session_id = SessionId::new(session_id);
712    store.ingest_turn(&ns, turn).await.map_err(internal)?;
713    Ok(StatusCode::NO_CONTENT)
714}
715
716// TurnsQuery is unified into NsQuery above.
717
718#[derive(Serialize)]
719struct TurnsResponse {
720    turns: Vec<SessionTurn>,
721}
722
723async fn session_turns(
724    principal: AuthPrincipal,
725    Extension(store): Extension<Arc<dyn Store>>,
726    Path(session_id): Path<String>,
727    Query(q): Query<NsQuery>,
728) -> Result<Json<TurnsResponse>, ApiError> {
729    if !principal.0.may(MEMORY_READ) {
730        return Err(ApiError::Forbidden);
731    }
732    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
733    let turns = store
734        .session_turns(&ns, &SessionId::new(session_id), q.limit.unwrap_or(50))
735        .await
736        .map_err(internal)?;
737    Ok(Json(TurnsResponse { turns }))
738}
739
740/// Creates (or upserts) a session's metadata. `ended_at` is forced to
741/// `None` on create — use `POST /sessions/:id/end` to close a session.
742/// Auth: `session:ingest`. The session is stored in the caller's
743/// personal namespace (matching turn ingest).
744async fn create_session(
745    principal: AuthPrincipal,
746    Extension(store): Extension<Arc<dyn Store>>,
747    Json(mut session): Json<Session>,
748) -> Result<Json<IdResponse>, ApiError> {
749    if !principal.0.may(SESSION_INGEST) {
750        return Err(ApiError::Forbidden);
751    }
752    let ns = principal.0.personal_namespace();
753    if session.started_at.is_empty() {
754        session.started_at = std::time::SystemTime::now()
755            .duration_since(std::time::UNIX_EPOCH)
756            .map(|d| d.as_secs().to_string())
757            .unwrap_or_default();
758    }
759    session.ended_at = None;
760    let id = store.create_session(&ns, session).await.map_err(internal)?;
761    Ok(Json(IdResponse { id: id.0 }))
762}
763
764#[derive(Deserialize)]
765struct SessionListQuery {
766    namespace: Option<String>,
767    /// Optional harness filter (wire string, e.g. `pi`).
768    harness: Option<String>,
769    limit: Option<usize>,
770}
771
772/// Lists sessions in the effective namespace, newest first, optionally
773/// filtered by harness. Auth: `memory:read` (session metadata is
774/// read via the same capability as memory palace reads).
775async fn list_sessions(
776    principal: AuthPrincipal,
777    Extension(store): Extension<Arc<dyn Store>>,
778    Query(q): Query<SessionListQuery>,
779) -> Result<Json<Vec<Session>>, ApiError> {
780    if !principal.0.may(MEMORY_READ) {
781        return Err(ApiError::Forbidden);
782    }
783    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
784    let harness = q.harness.as_deref().map(Harness::from_wire_str);
785    let limit = q.limit.unwrap_or(50).min(500);
786    let sessions = store
787        .list_sessions(&ns, harness.as_ref(), limit)
788        .await
789        .map_err(internal)?;
790    Ok(Json(sessions))
791}
792
793#[derive(Deserialize)]
794struct EndSessionRequest {
795    ended_at: String,
796}
797
798/// Marks a session as ended. Scoped to the caller's personal namespace.
799/// Auth: `session:ingest`.
800async fn end_session(
801    principal: AuthPrincipal,
802    Extension(store): Extension<Arc<dyn Store>>,
803    Path(session_id): Path<String>,
804    Json(req): Json<EndSessionRequest>,
805) -> Result<StatusCode, ApiError> {
806    if !principal.0.may(SESSION_INGEST) {
807        return Err(ApiError::Forbidden);
808    }
809    let ns = principal.0.personal_namespace();
810    store
811        .end_session(&ns, &SessionId::new(session_id), req.ended_at)
812        .await
813        .map_err(internal)?;
814    Ok(StatusCode::NO_CONTENT)
815}
816
817// ---------- mining review queue (ADR M2, M3) ----------
818
819/// Lists pending mining extractions in the effective namespace, newest
820/// first. Auth: `mining:review`.
821async fn list_pending(
822    principal: AuthPrincipal,
823    Extension(store): Extension<Arc<dyn Store>>,
824    Query(q): Query<NsQuery>,
825) -> Result<Json<Vec<QueuedExtraction>>, ApiError> {
826    if !principal.0.may(MINING_REVIEW) {
827        return Err(ApiError::Forbidden);
828    }
829    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
830    let limit = q.limit.unwrap_or(50).min(500);
831    let pending = store.list_pending(&ns, limit).await.map_err(internal)?;
832    Ok(Json(pending))
833}
834
835/// Accepts a queued extraction: promotes it to the palace and removes it
836/// from the queue. Auth: `mining:review`.
837async fn accept_extraction(
838    principal: AuthPrincipal,
839    Extension(store): Extension<Arc<dyn Store>>,
840    Path(id): Path<String>,
841) -> Result<Json<AcceptedExtraction>, ApiError> {
842    if !principal.0.may(MINING_REVIEW) {
843        return Err(ApiError::Forbidden);
844    }
845    let ns = principal.0.personal_namespace();
846    let accepted = store.accept_extraction(&ns, &id).await.map_err(internal)?;
847    Ok(Json(accepted))
848}
849
850/// Rejects a queued extraction: drops it without promoting. Auth:
851/// `mining:review`. Returns 204.
852async fn reject_extraction(
853    principal: AuthPrincipal,
854    Extension(store): Extension<Arc<dyn Store>>,
855    Path(id): Path<String>,
856) -> Result<StatusCode, ApiError> {
857    if !principal.0.may(MINING_REVIEW) {
858        return Err(ApiError::Forbidden);
859    }
860    let ns = principal.0.personal_namespace();
861    store.reject_extraction(&ns, &id).await.map_err(internal)?;
862    Ok(StatusCode::NO_CONTENT)
863}
864
865// ---------- mining trigger (ADR M1, M3, M7) ----------
866
867/// Triggers an extraction pass over a session's turns: runs the rules tier
868/// (always) plus the llm tier when `IJIMA_LLM_*` is configured, merges +
869/// content-dedups, then ingests — `Auto` extractions archive to the palace,
870/// `PendingReview` stage in the review queue. Auth: `mining:trigger`.
871///
872/// The llm agent's `HttpAgent::respond` blocks on its own tokio runtime, so
873/// the synchronous `mine_all` pass runs on a blocking thread (via
874/// [`tokio::task::spawn_blocking`]) to avoid a runtime-in-runtime panic
875/// inside this async handler. The concrete [`HttpAgent`] is `Send`; the
876/// `&mut dyn Agent` coercion happens *inside* the closure, so it never
877/// crosses the spawn boundary as an unsized non-`Send` trait object.
878#[cfg(feature = "mining")]
879async fn trigger_mine(
880    principal: AuthPrincipal,
881    Extension(store): Extension<Arc<dyn Store>>,
882    Path(session_id): Path<String>,
883    Query(q): Query<NsQuery>,
884) -> Result<Json<crate::mining_pipeline::MiningReport>, ApiError> {
885    use proserpina::backend::http::HttpAgent;
886
887    if !principal.0.may(MINING_TRIGGER) {
888        return Err(ApiError::Forbidden);
889    }
890    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
891
892    // Fetch the session's turns (a generous limit — v0 mines the whole session).
893    let turns = store
894        .session_turns(&ns, &SessionId::new(session_id.clone()), 10_000)
895        .await
896        .map_err(internal)?;
897    let turn_texts: Vec<String> = turns.into_iter().map(|t| t.content).collect();
898    let ctx = crate::mining_pipeline::mining_context(&session_id, "general", Harness::Other);
899
900    // The extraction pass is synchronous (ADR M1); the llm agent bridges to
901    // async HTTP internally via its own runtime + `block_on`. Run it on a
902    // blocking thread so that `block_on` is legal (we are outside any async
903    // executor here). `build_mining_agent` returns a concrete `Option<HttpAgent>`
904    // — kept as the concrete type (not a trait object) so it stays `Send` for
905    // the move into the spawned task.
906    let extractions = tokio::task::spawn_blocking(move || {
907        let mut agent: Option<HttpAgent> = build_mining_agent();
908        let agent_dyn: Option<&mut dyn proserpina::Agent> =
909            agent.as_mut().map(|a| a as &mut dyn proserpina::Agent);
910        ijima_miner::mine_all(&turn_texts, &ctx, agent_dyn)
911    })
912    .await
913    .map_err(|e| {
914        internal(ijima_core::IjimaError::Mining {
915            detail: format!("extraction task failed: {e}"),
916        })
917    })?
918    .map_err(internal)?;
919
920    let report = crate::mining_pipeline::ingest_extractions(store.as_ref(), &ns, extractions)
921        .await
922        .map_err(internal)?;
923    Ok(Json(report))
924}
925
926/// Constructs the llm extraction agent from `IJIMA_LLM_*` env config, or
927/// `None` when mining should run rules-only (no `IJIMA_LLM_MODEL` /
928/// `IJIMA_LLM_API_KEY` set). `mine_all(None)` then skips the llm tier.
929///
930/// Defaults `IJIMA_LLM_BASE_URL` to the DeepSeek endpoint. The agent uses a
931/// single "Session Mining Extractor" persona covering both fact and pattern
932/// extraction; v0 does not vary the agent persona per role (ADR M5,
933/// single-shot). Returns a concrete [`HttpAgent`] (not a trait object) so it
934/// remains `Send` for the blocking-thread move.
935#[cfg(feature = "mining")]
936fn build_mining_agent() -> Option<proserpina::backend::http::HttpAgent> {
937    use proserpina::{
938        AgentId, Persona,
939        backend::http::{HttpAgent, HttpConfig},
940    };
941
942    let base_url = std::env::var("IJIMA_LLM_BASE_URL")
943        .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string());
944    let model = std::env::var("IJIMA_LLM_MODEL").ok()?;
945    let api_key = std::env::var("IJIMA_LLM_API_KEY").ok()?;
946
947    let persona = Persona::new("Session Mining Extractor")
948        .with_framing(
949            "You mine session transcripts for durable facts and recurring \
950             patterns. Output one JSON object per line, each \
951             {\"content\",\"project\",\"topic\",\"confidence\"}. Omit all \
952             preamble. If nothing worth extracting, output nothing.",
953        )
954        .with_focus(
955            "decisions, chosen tools, stated constraints, measurements, recurring workflows",
956        );
957
958    Some(HttpAgent::new(
959        AgentId::new("ijima-miner"),
960        persona,
961        HttpConfig {
962            base_url,
963            model,
964            api_key,
965        },
966    ))
967}
968
969// ===== Palace organization (memory:read) =====
970
971#[derive(Deserialize)]
972struct NamespaceQuery {
973    namespace: Option<String>,
974}
975
976#[derive(Deserialize)]
977struct RoomsQuery {
978    namespace: Option<String>,
979    project: Option<String>,
980    limit: Option<usize>,
981}
982
983#[derive(Deserialize)]
984struct TunnelQuery {
985    namespace: Option<String>,
986    topic: String,
987    project_a: String,
988    project_b: String,
989    limit: Option<usize>,
990}
991
992#[derive(Deserialize)]
993struct DiaryQuery {
994    namespace: Option<String>,
995    limit: Option<usize>,
996}
997
998#[derive(Deserialize)]
999struct MemoryBrowseQuery {
1000    namespace: Option<String>,
1001    project: Option<String>,
1002    topic: Option<String>,
1003    limit: Option<usize>,
1004}
1005
1006#[derive(Deserialize)]
1007struct ResolveRepoQuery {
1008    cwd: String,
1009}
1010
1011/// Lists rooms (topic cells), optionally filtered to a project. Auth: `memory:read`.
1012async fn list_rooms(
1013    principal: AuthPrincipal,
1014    Extension(store): Extension<Arc<dyn Store>>,
1015    Query(q): Query<RoomsQuery>,
1016) -> Result<Json<Vec<Room>>, ApiError> {
1017    if !principal.0.may(MEMORY_READ) {
1018        return Err(ApiError::Forbidden);
1019    }
1020    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1021    let limit = q.limit.unwrap_or(50).min(500);
1022    let rooms = store
1023        .list_rooms(&ns, q.project.as_deref(), limit)
1024        .await
1025        .map_err(internal)?;
1026    Ok(Json(rooms))
1027}
1028
1029/// Full project → topic → count taxonomy. Auth: `memory:read`.
1030async fn taxonomy(
1031    principal: AuthPrincipal,
1032    Extension(store): Extension<Arc<dyn Store>>,
1033    Query(q): Query<NamespaceQuery>,
1034) -> Result<Json<Vec<ProjectTaxon>>, ApiError> {
1035    if !principal.0.may(MEMORY_READ) {
1036        return Err(ApiError::Forbidden);
1037    }
1038    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1039    Ok(Json(store.taxonomy(&ns).await.map_err(internal)?))
1040}
1041
1042/// The palace graph: projects as nodes, shared-topic tunnels as edges. Auth: `memory:read`.
1043async fn palace_graph(
1044    principal: AuthPrincipal,
1045    Extension(store): Extension<Arc<dyn Store>>,
1046    Query(q): Query<NamespaceQuery>,
1047) -> Result<Json<PalaceGraph>, ApiError> {
1048    if !principal.0.may(MEMORY_READ) {
1049        return Err(ApiError::Forbidden);
1050    }
1051    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1052    Ok(Json(store.palace_graph(&ns).await.map_err(internal)?))
1053}
1054
1055/// Traverses a tunnel — the memories from both projects on a shared topic. Auth: `memory:read`.
1056async fn traverse_tunnel(
1057    principal: AuthPrincipal,
1058    Extension(store): Extension<Arc<dyn Store>>,
1059    Query(q): Query<TunnelQuery>,
1060) -> Result<Json<TunnelTraversal>, ApiError> {
1061    if !principal.0.may(MEMORY_READ) {
1062        return Err(ApiError::Forbidden);
1063    }
1064    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1065    let limit = q.limit.unwrap_or(50).min(500);
1066    Ok(Json(
1067        store
1068            .traverse_tunnel(&ns, &q.topic, &q.project_a, &q.project_b, limit)
1069            .await
1070            .map_err(internal)?,
1071    ))
1072}
1073
1074/// Appends a diary entry to the caller's namespace. Auth: `memory:write`.
1075async fn write_diary(
1076    principal: AuthPrincipal,
1077    Extension(store): Extension<Arc<dyn Store>>,
1078    Json(entry): Json<DiaryEntry>,
1079) -> Result<StatusCode, ApiError> {
1080    if !principal.0.may(MEMORY_WRITE) {
1081        return Err(ApiError::Forbidden);
1082    }
1083    let ns = principal.0.personal_namespace();
1084    store.write_diary(&ns, entry).await.map_err(internal)?;
1085    Ok(StatusCode::NO_CONTENT)
1086}
1087
1088/// Reads `agent`'s diary in the caller's namespace. Auth: `memory:read`.
1089async fn read_diary(
1090    principal: AuthPrincipal,
1091    Extension(store): Extension<Arc<dyn Store>>,
1092    Path(agent): Path<String>,
1093    Query(q): Query<DiaryQuery>,
1094) -> Result<Json<Vec<DiaryEntry>>, ApiError> {
1095    if !principal.0.may(MEMORY_READ) {
1096        return Err(ApiError::Forbidden);
1097    }
1098    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1099    let limit = q.limit.unwrap_or(50).min(500);
1100    Ok(Json(
1101        store
1102            .read_diary(&ns, &agent, limit)
1103            .await
1104            .map_err(internal)?,
1105    ))
1106}
1107
1108/// Browses memories (the `memory_recall` path), optionally filtered to
1109/// project/topic — distinct from the importance-ranked wake-up feed. Auth: `memory:read`.
1110async fn browse_memories(
1111    principal: AuthPrincipal,
1112    Extension(store): Extension<Arc<dyn Store>>,
1113    Query(q): Query<MemoryBrowseQuery>,
1114) -> Result<Json<Vec<Memory>>, ApiError> {
1115    if !principal.0.may(MEMORY_READ) {
1116        return Err(ApiError::Forbidden);
1117    }
1118    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1119    let limit = q.limit.unwrap_or(50).min(500);
1120    Ok(Json(
1121        store
1122            .list_memories_filtered(&ns, q.project.as_deref(), q.topic.as_deref(), limit)
1123            .await
1124            .map_err(internal)?,
1125    ))
1126}
1127
1128#[derive(Serialize)]
1129struct NamespaceStats {
1130    total: usize,
1131    projects: Vec<ProjectCount>,
1132}
1133
1134#[derive(Serialize)]
1135struct ProjectCount {
1136    project: String,
1137    count: usize,
1138}
1139
1140/// Read-accessible namespace stats (derived from room counts; unlike
1141/// `/status` which is admin-gated). Auth: `memory:read`.
1142async fn memory_stats(
1143    principal: AuthPrincipal,
1144    Extension(store): Extension<Arc<dyn Store>>,
1145    Query(q): Query<NamespaceQuery>,
1146) -> Result<Json<NamespaceStats>, ApiError> {
1147    if !principal.0.may(MEMORY_READ) {
1148        return Err(ApiError::Forbidden);
1149    }
1150    let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1151    let rooms = store.list_rooms(&ns, None, 1000).await.map_err(internal)?;
1152    let total: usize = rooms.iter().map(|r| r.count).sum();
1153    let mut by_project: std::collections::BTreeMap<String, usize> =
1154        std::collections::BTreeMap::new();
1155    for r in &rooms {
1156        *by_project.entry(r.project.clone()).or_default() += r.count;
1157    }
1158    let projects = by_project
1159        .into_iter()
1160        .map(|(project, count)| ProjectCount { project, count })
1161        .collect();
1162    Ok(Json(NamespaceStats { total, projects }))
1163}
1164
1165// ===== Repo directory (global registry — Context Mapper) =====
1166
1167/// Registers/upserts a repo in the global registry (operator action). Auth: `admin`.
1168async fn register_repo(
1169    principal: AuthPrincipal,
1170    Extension(store): Extension<Arc<dyn Store>>,
1171    Json(repo): Json<RepoDirectory>,
1172) -> Result<StatusCode, ApiError> {
1173    if !principal.0.may(ADMIN) {
1174        return Err(ApiError::Forbidden);
1175    }
1176    store.register_repo(repo).await.map_err(internal)?;
1177    Ok(StatusCode::NO_CONTENT)
1178}
1179
1180/// Lists every registered repo (the ecosystem roster). Auth: `memory:read`.
1181async fn list_repos(
1182    principal: AuthPrincipal,
1183    Extension(store): Extension<Arc<dyn Store>>,
1184) -> Result<Json<Vec<RepoDirectory>>, ApiError> {
1185    if !principal.0.may(MEMORY_READ) {
1186        return Err(ApiError::Forbidden);
1187    }
1188    Ok(Json(store.list_repos().await.map_err(internal)?))
1189}
1190
1191/// Reverse-resolves a working directory to its registered repo. Auth: `memory:read`.
1192async fn resolve_repo(
1193    principal: AuthPrincipal,
1194    Extension(store): Extension<Arc<dyn Store>>,
1195    Query(q): Query<ResolveRepoQuery>,
1196) -> Result<Json<RepoDirectory>, ApiError> {
1197    if !principal.0.may(MEMORY_READ) {
1198        return Err(ApiError::Forbidden);
1199    }
1200    match store.resolve_repo(&q.cwd).await.map_err(internal)? {
1201        Some(repo) => Ok(Json(repo)),
1202        None => Err(ApiError::NotFound),
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209    use crate::IjimaAuth;
1210    use axum::body::Body;
1211    use axum::http::{Request, StatusCode};
1212    use ijima_core::{harness::Harness, memory::MemorySource};
1213    use tower::ServiceExt;
1214
1215    async fn app_with_store() -> (Router, Arc<IjimaAuth>) {
1216        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1217        let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1218        let store: Arc<dyn Store> = store_inner.clone();
1219        let kg: Arc<dyn KnowledgeGraph> = store_inner;
1220        (
1221            app(
1222                auth.clone(),
1223                store,
1224                kg,
1225                None,
1226                Arc::new(crate::redaction::Redactor::new()),
1227                #[cfg(feature = "rate-limit")]
1228                None,
1229            ),
1230            auth,
1231        )
1232    }
1233
1234    fn bearer(auth: &IjimaAuth, principal: &str, cap: &str) -> String {
1235        format!(
1236            "Bearer {}",
1237            auth.issue_bearer(principal, cap).expect("issue")
1238        )
1239    }
1240
1241    fn sample_memory_json(id: &str) -> String {
1242        serde_json::json!({
1243            "id": id,
1244            "content": "decided to wire the daemon",
1245            "project": "ijima",
1246            "topic": "api",
1247            "source": "Explicit",
1248            "harness": "Pi",
1249            "session_id": "sess_1",
1250            "importance": 0.5,
1251            "created_at": "0",
1252        })
1253        .to_string()
1254    }
1255
1256    #[tokio::test]
1257    async fn health_is_public() {
1258        let (app, _) = app_with_store().await;
1259        let res = app
1260            .oneshot(
1261                Request::builder()
1262                    .uri("/health")
1263                    .body(Body::empty())
1264                    .unwrap(),
1265            )
1266            .await
1267            .unwrap();
1268        assert_eq!(res.status(), StatusCode::OK);
1269    }
1270
1271    #[tokio::test]
1272    async fn recall_without_auth_is_401() {
1273        let (app, _) = app_with_store().await;
1274        let res = app
1275            .oneshot(
1276                Request::builder()
1277                    .uri("/memories/mem_1")
1278                    .body(Body::empty())
1279                    .unwrap(),
1280            )
1281            .await
1282            .unwrap();
1283        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
1284    }
1285
1286    #[tokio::test]
1287    async fn store_then_recall_round_trips() {
1288        let (app, auth) = app_with_store().await;
1289        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1290        let read = bearer(&auth, "elliott", MEMORY_READ);
1291
1292        // POST /memories
1293        let res = app
1294            .clone()
1295            .oneshot(
1296                Request::builder()
1297                    .method("POST")
1298                    .uri("/memories")
1299                    .header("authorization", &write)
1300                    .header("content-type", "application/json")
1301                    .body(Body::from(sample_memory_json("mem_1")))
1302                    .unwrap(),
1303            )
1304            .await
1305            .unwrap();
1306        assert_eq!(res.status(), StatusCode::OK);
1307
1308        // GET /memories/mem_1
1309        let res = app
1310            .oneshot(
1311                Request::builder()
1312                    .uri("/memories/mem_1")
1313                    .header("authorization", &read)
1314                    .body(Body::empty())
1315                    .unwrap(),
1316            )
1317            .await
1318            .unwrap();
1319        assert_eq!(res.status(), StatusCode::OK);
1320        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1321            .await
1322            .unwrap();
1323        let mem: Memory = serde_json::from_slice(&body).unwrap();
1324        assert_eq!(mem.content, "decided to wire the daemon");
1325        assert_eq!(mem.harness, Harness::Pi);
1326        assert_eq!(mem.source, MemorySource::Explicit);
1327    }
1328
1329    #[tokio::test]
1330    async fn store_with_read_only_token_is_403() {
1331        let (app, auth) = app_with_store().await;
1332        let read = bearer(&auth, "elliott", MEMORY_READ);
1333        let res = app
1334            .oneshot(
1335                Request::builder()
1336                    .method("POST")
1337                    .uri("/memories")
1338                    .header("authorization", &read)
1339                    .header("content-type", "application/json")
1340                    .body(Body::from(sample_memory_json("mem_x")))
1341                    .unwrap(),
1342            )
1343            .await
1344            .unwrap();
1345        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1346    }
1347
1348    #[tokio::test]
1349    async fn namespace_isolation_across_principals() {
1350        let (app, auth) = app_with_store().await;
1351        // alice stores
1352        let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1353        let _ = app
1354            .clone()
1355            .oneshot(
1356                Request::builder()
1357                    .method("POST")
1358                    .uri("/memories")
1359                    .header("authorization", &alice_write)
1360                    .header("content-type", "application/json")
1361                    .body(Body::from(sample_memory_json("mem_a")))
1362                    .unwrap(),
1363            )
1364            .await
1365            .unwrap();
1366        // bob cannot recall alice's memory
1367        let bob_read = bearer(&auth, "bob", MEMORY_READ);
1368        let res = app
1369            .oneshot(
1370                Request::builder()
1371                    .uri("/memories/mem_a")
1372                    .header("authorization", &bob_read)
1373                    .body(Body::empty())
1374                    .unwrap(),
1375            )
1376            .await
1377            .unwrap();
1378        assert_eq!(res.status(), StatusCode::NOT_FOUND);
1379    }
1380
1381    #[tokio::test]
1382    async fn promote_redacts_secrets_and_leaves_original_intact() {
1383        let (app, auth) = app_with_store().await;
1384        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1385        let read = bearer(&auth, "elliott", MEMORY_READ);
1386        let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1387
1388        // Store a personal memory containing a secret.
1389        let body = serde_json::json!({
1390            "id": "mem_secret",
1391            "content": "deploy key sk-abcdefghijklmnopqrstuvwxyz1234567890 contact ops@test.com",
1392            "project": "ijima",
1393            "topic": "ops",
1394            "source": "Explicit",
1395            "harness": "Pi",
1396        })
1397        .to_string();
1398        let res = app
1399            .clone()
1400            .oneshot(
1401                Request::builder()
1402                    .method("POST")
1403                    .uri("/memories")
1404                    .header("authorization", &write)
1405                    .header("content-type", "application/json")
1406                    .body(Body::from(body))
1407                    .unwrap(),
1408            )
1409            .await
1410            .unwrap();
1411        assert_eq!(res.status(), StatusCode::OK);
1412
1413        // Promote to a shared namespace.
1414        let promote_body = serde_json::json!({
1415            "target_namespace": "ns_team_shared",
1416        })
1417        .to_string();
1418        let res = app
1419            .clone()
1420            .oneshot(
1421                Request::builder()
1422                    .method("POST")
1423                    .uri("/memories/mem_secret/promote")
1424                    .header("authorization", &promote)
1425                    .header("content-type", "application/json")
1426                    .body(Body::from(promote_body))
1427                    .unwrap(),
1428            )
1429            .await
1430            .unwrap();
1431        assert_eq!(res.status(), StatusCode::OK);
1432        let resp: serde_json::Value = serde_json::from_slice(
1433            &axum::body::to_bytes(res.into_body(), usize::MAX)
1434                .await
1435                .unwrap(),
1436        )
1437        .unwrap();
1438        let new_id = resp["id"].as_str().unwrap();
1439        assert_eq!(new_id, "mem_secret__shared");
1440        let cats: Vec<&str> = resp["redactions"]
1441            .as_array()
1442            .unwrap()
1443            .iter()
1444            .map(|r| r["category"].as_str().unwrap())
1445            .collect();
1446        assert!(cats.contains(&"api_key"));
1447        assert!(cats.contains(&"email"));
1448
1449        // The original personal memory is untouched (verbatim).
1450        let res = app
1451            .clone()
1452            .oneshot(
1453                Request::builder()
1454                    .uri("/memories/mem_secret")
1455                    .header("authorization", &read)
1456                    .body(Body::empty())
1457                    .unwrap(),
1458            )
1459            .await
1460            .unwrap();
1461        let orig: Memory = serde_json::from_slice(
1462            &axum::body::to_bytes(res.into_body(), usize::MAX)
1463                .await
1464                .unwrap(),
1465        )
1466        .unwrap();
1467        assert!(orig.content.contains("sk-abcdef"));
1468        assert!(orig.content.contains("ops@test.com"));
1469
1470        // The promoted shared copy is readable via ?namespace= and has
1471        // secrets scrubbed.
1472        let res = app
1473            .oneshot(
1474                Request::builder()
1475                    .uri("/memories/mem_secret__shared?namespace=ns_team_shared")
1476                    .header("authorization", &read)
1477                    .body(Body::empty())
1478                    .unwrap(),
1479            )
1480            .await
1481            .unwrap();
1482        assert_eq!(res.status(), StatusCode::OK);
1483        let shared: Memory = serde_json::from_slice(
1484            &axum::body::to_bytes(res.into_body(), usize::MAX)
1485                .await
1486                .unwrap(),
1487        )
1488        .unwrap();
1489        assert!(shared.content.contains("[REDACTED:api_key]"));
1490        assert!(shared.content.contains("[REDACTED:email]"));
1491        assert!(!shared.content.contains("sk-abcdef"));
1492        assert!(!shared.content.contains("ops@test.com"));
1493        // Provenance back-reference.
1494        assert_eq!(shared.session_id.as_deref(), Some("mem_secret"));
1495    }
1496
1497    #[tokio::test]
1498    async fn promote_requires_trust_promote_not_memory_write() {
1499        // ADR provenance-tier: raising trust is costlier than writing at a
1500        // tier, so promote_memory requires trust:promote (codim 4), not
1501        // memory:write (codim 2). A memory:write-only token gets 403.
1502        let (app, auth) = app_with_store().await;
1503        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1504        let body = serde_json::json!({
1505            "id": "mem_p",
1506            "content": "provenance tier test",
1507            "project": "ijima",
1508            "topic": "t",
1509            "source": "Explicit",
1510            "harness": "Pi",
1511        })
1512        .to_string();
1513        // Store succeeds with memory:write.
1514        let res = app
1515            .clone()
1516            .oneshot(
1517                Request::builder()
1518                    .method("POST")
1519                    .uri("/memories")
1520                    .header("authorization", &write)
1521                    .header("content-type", "application/json")
1522                    .body(Body::from(body))
1523                    .unwrap(),
1524            )
1525            .await
1526            .unwrap();
1527        assert_eq!(res.status(), StatusCode::OK);
1528
1529        // Promote is forbidden with only memory:write.
1530        let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
1531        let res = app
1532            .clone()
1533            .oneshot(
1534                Request::builder()
1535                    .method("POST")
1536                    .uri("/memories/mem_p/promote")
1537                    .header("authorization", &write)
1538                    .header("content-type", "application/json")
1539                    .body(Body::from(promote_body))
1540                    .unwrap(),
1541            )
1542            .await
1543            .unwrap();
1544        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1545
1546        // A trust:promote holder succeeds.
1547        let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1548        let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
1549        let res = app
1550            .oneshot(
1551                Request::builder()
1552                    .method("POST")
1553                    .uri("/memories/mem_p/promote")
1554                    .header("authorization", &promote)
1555                    .header("content-type", "application/json")
1556                    .body(Body::from(promote_body))
1557                    .unwrap(),
1558            )
1559            .await
1560            .unwrap();
1561        assert_eq!(res.status(), StatusCode::OK);
1562    }
1563
1564    #[tokio::test]
1565    async fn cross_principal_personal_namespace_is_forbidden() {
1566        let (app, auth) = app_with_store().await;
1567        // Alice stores a memory.
1568        let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1569        let _ = app
1570            .clone()
1571            .oneshot(
1572                Request::builder()
1573                    .method("POST")
1574                    .uri("/memories")
1575                    .header("authorization", &alice_write)
1576                    .header("content-type", "application/json")
1577                    .body(Body::from(
1578                        serde_json::json!({
1579                            "id": "mem_a",
1580                            "content": "alice only",
1581                            "project": "x",
1582                            "topic": "x",
1583                            "source": "Explicit",
1584                            "harness": "Pi",
1585                        })
1586                        .to_string(),
1587                    ))
1588                    .unwrap(),
1589            )
1590            .await
1591            .unwrap();
1592
1593        // Bob tries to read alice's personal namespace explicitly.
1594        let bob_read = bearer(&auth, "bob", MEMORY_READ);
1595        let res = app
1596            .oneshot(
1597                Request::builder()
1598                    .uri("/memories/mem_a?namespace=ns_alice_private")
1599                    .header("authorization", &bob_read)
1600                    .body(Body::empty())
1601                    .unwrap(),
1602            )
1603            .await
1604            .unwrap();
1605        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1606    }
1607
1608    #[tokio::test]
1609    async fn doctrine_ingest_requires_admin_and_is_readable_shared() {
1610        let (app, auth) = app_with_store().await;
1611        let admin = bearer(&auth, "ci", "admin");
1612        let read = bearer(&auth, "anyone", MEMORY_READ);
1613
1614        // Non-admin cannot ingest doctrine.
1615        let res = app
1616            .clone()
1617            .oneshot(
1618                Request::builder()
1619                    .method("POST")
1620                    .uri("/doctrine")
1621                    .header("authorization", &read)
1622                    .header("content-type", "application/json")
1623                    .body(Body::from(
1624                        serde_json::json!({
1625                            "id": "d1",
1626                            "content": "doctrine body",
1627                            "project": "ijima",
1628                            "topic": "arch",
1629                        })
1630                        .to_string(),
1631                    ))
1632                    .unwrap(),
1633            )
1634            .await
1635            .unwrap();
1636        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1637
1638        // Admin ingests.
1639        let res = app
1640            .clone()
1641            .oneshot(
1642                Request::builder()
1643                    .method("POST")
1644                    .uri("/doctrine")
1645                    .header("authorization", &admin)
1646                    .header("content-type", "application/json")
1647                    .body(Body::from(
1648                        serde_json::json!({
1649                            "id": "d1",
1650                            "content": "doctrine body",
1651                            "project": "ijima",
1652                            "topic": "arch",
1653                        })
1654                        .to_string(),
1655                    ))
1656                    .unwrap(),
1657            )
1658            .await
1659            .unwrap();
1660        assert_eq!(res.status(), StatusCode::OK);
1661
1662        // Any read-capable principal can recall doctrine from ns_doctrine.
1663        let res = app
1664            .oneshot(
1665                Request::builder()
1666                    .uri("/memories/d1?namespace=ns_doctrine")
1667                    .header("authorization", &read)
1668                    .body(Body::empty())
1669                    .unwrap(),
1670            )
1671            .await
1672            .unwrap();
1673        assert_eq!(res.status(), StatusCode::OK);
1674        let mem: Memory = serde_json::from_slice(
1675            &axum::body::to_bytes(res.into_body(), usize::MAX)
1676                .await
1677                .unwrap(),
1678        )
1679        .unwrap();
1680        assert_eq!(mem.content, "doctrine body");
1681        assert_eq!(mem.source, ijima_core::memory::MemorySource::Doctrine);
1682    }
1683
1684    #[tokio::test]
1685    async fn wakeup_composes_personal_and_doctrine() {
1686        let (app, auth) = app_with_store().await;
1687        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1688        let admin = bearer(&auth, "ci", "admin");
1689        let read = bearer(&auth, "elliott", MEMORY_READ);
1690
1691        // Store a personal memory.
1692        let _ = app
1693            .clone()
1694            .oneshot(
1695                Request::builder()
1696                    .method("POST")
1697                    .uri("/memories")
1698                    .header("authorization", &write)
1699                    .header("content-type", "application/json")
1700                    .body(Body::from(
1701                        serde_json::json!({
1702                            "id": "mem_p",
1703                            "content": "personal essential",
1704                            "project": "ijima",
1705                            "topic": "x",
1706                            "source": "Explicit",
1707                            "harness": "Pi",
1708                        })
1709                        .to_string(),
1710                    ))
1711                    .unwrap(),
1712            )
1713            .await
1714            .unwrap();
1715
1716        // Ingest doctrine.
1717        let _ = app
1718            .clone()
1719            .oneshot(
1720                Request::builder()
1721                    .method("POST")
1722                    .uri("/doctrine")
1723                    .header("authorization", &admin)
1724                    .header("content-type", "application/json")
1725                    .body(Body::from(
1726                        serde_json::json!({
1727                            "id": "doc_1",
1728                            "content": "doctrine baseline",
1729                            "project": "ijima",
1730                            "topic": "arch",
1731                        })
1732                        .to_string(),
1733                    ))
1734                    .unwrap(),
1735            )
1736            .await
1737            .unwrap();
1738
1739        // Wake-up composes both.
1740        let res = app
1741            .oneshot(
1742                Request::builder()
1743                    .uri("/wakeup")
1744                    .header("authorization", &read)
1745                    .body(Body::empty())
1746                    .unwrap(),
1747            )
1748            .await
1749            .unwrap();
1750        assert_eq!(res.status(), StatusCode::OK);
1751        let body: serde_json::Value = serde_json::from_slice(
1752            &axum::body::to_bytes(res.into_body(), usize::MAX)
1753                .await
1754                .unwrap(),
1755        )
1756        .unwrap();
1757        assert_eq!(body["identity"]["principal"], "elliott");
1758        assert_eq!(body["personal_essentials"].as_array().unwrap().len(), 1);
1759        assert_eq!(
1760            body["personal_essentials"][0]["content"],
1761            "personal essential"
1762        );
1763        assert_eq!(body["doctrine"].as_array().unwrap().len(), 1);
1764        assert_eq!(body["doctrine"][0]["content"], "doctrine baseline");
1765        assert_eq!(body["doctrine"][0]["source"], "Doctrine");
1766    }
1767
1768    #[tokio::test]
1769    async fn knowledge_graph_add_query_invalidate() {
1770        let (app, auth) = app_with_store().await;
1771        let write = bearer(&auth, "elliott", "knowledge:write");
1772        let read = bearer(&auth, "elliott", "knowledge:read");
1773
1774        // Add a triple.
1775        let res = app
1776            .clone()
1777            .oneshot(
1778                Request::builder()
1779                    .method("POST")
1780                    .uri("/kg/triples")
1781                    .header("authorization", &write)
1782                    .header("content-type", "application/json")
1783                    .body(Body::from(
1784                        serde_json::json!({
1785                            "subject": "Ijima",
1786                            "predicate": "depends_on",
1787                            "object": "SurrealDB",
1788                            "confidence": 1.0,
1789                        })
1790                        .to_string(),
1791                    ))
1792                    .unwrap(),
1793            )
1794            .await
1795            .unwrap();
1796        assert_eq!(res.status(), StatusCode::OK);
1797
1798        // Query the entity — outgoing edge present.
1799        let res = app
1800            .clone()
1801            .oneshot(
1802                Request::builder()
1803                    .uri("/kg/entities/Ijima")
1804                    .header("authorization", &read)
1805                    .body(Body::empty())
1806                    .unwrap(),
1807            )
1808            .await
1809            .unwrap();
1810        assert_eq!(res.status(), StatusCode::OK);
1811        let body: serde_json::Value = serde_json::from_slice(
1812            &axum::body::to_bytes(res.into_body(), usize::MAX)
1813                .await
1814                .unwrap(),
1815        )
1816        .unwrap();
1817        assert_eq!(body["outgoing"].as_array().unwrap().len(), 1);
1818        assert_eq!(body["outgoing"][0]["object"], "SurrealDB");
1819        assert!(body["incoming"].as_array().unwrap().is_empty());
1820
1821        // Stats.
1822        let res = app
1823            .clone()
1824            .oneshot(
1825                Request::builder()
1826                    .uri("/kg/stats")
1827                    .header("authorization", &read)
1828                    .body(Body::empty())
1829                    .unwrap(),
1830            )
1831            .await
1832            .unwrap();
1833        let body: serde_json::Value = serde_json::from_slice(
1834            &axum::body::to_bytes(res.into_body(), usize::MAX)
1835                .await
1836                .unwrap(),
1837        )
1838        .unwrap();
1839        assert_eq!(body["entities"], 2);
1840        assert_eq!(body["triples"], 1);
1841
1842        // Invalidate.
1843        let res = app
1844            .oneshot(
1845                Request::builder()
1846                    .method("POST")
1847                    .uri("/kg/triples/Ijima:depends_on:SurrealDB/invalidate")
1848                    .header("authorization", &write)
1849                    .body(Body::empty())
1850                    .unwrap(),
1851            )
1852            .await
1853            .unwrap();
1854        assert_eq!(res.status(), StatusCode::NO_CONTENT);
1855    }
1856
1857    #[tokio::test]
1858    async fn status_requires_admin_and_reports_counts() {
1859        let (app, auth) = app_with_store().await;
1860        let admin = bearer(&auth, "op", "admin");
1861        let read = bearer(&auth, "user", MEMORY_READ);
1862
1863        // Store a memory + a triple so counts are non-zero.
1864        let _ = app
1865            .clone()
1866            .oneshot(
1867                Request::builder()
1868                    .method("POST")
1869                    .uri("/memories")
1870                    .header("authorization", &admin)
1871                    .header("content-type", "application/json")
1872                    .body(Body::from(
1873                        serde_json::json!({
1874                            "id": "m1",
1875                            "content": "stat test",
1876                            "project": "x",
1877                            "topic": "x",
1878                            "source": "Explicit",
1879                            "harness": "Pi",
1880                        })
1881                        .to_string(),
1882                    ))
1883                    .unwrap(),
1884            )
1885            .await
1886            .unwrap();
1887
1888        // Non-admin is forbidden.
1889        let res = app
1890            .clone()
1891            .oneshot(
1892                Request::builder()
1893                    .uri("/status")
1894                    .header("authorization", &read)
1895                    .body(Body::empty())
1896                    .unwrap(),
1897            )
1898            .await
1899            .unwrap();
1900        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1901
1902        // Admin sees global counts.
1903        let res = app
1904            .oneshot(
1905                Request::builder()
1906                    .uri("/status")
1907                    .header("authorization", &admin)
1908                    .body(Body::empty())
1909                    .unwrap(),
1910            )
1911            .await
1912            .unwrap();
1913        assert_eq!(res.status(), StatusCode::OK);
1914        let body: serde_json::Value = serde_json::from_slice(
1915            &axum::body::to_bytes(res.into_body(), usize::MAX)
1916                .await
1917                .unwrap(),
1918        )
1919        .unwrap();
1920        assert_eq!(body["memories"], 1);
1921        assert!(!body["namespaces"].as_array().unwrap().is_empty());
1922    }
1923
1924    #[tokio::test]
1925    async fn sessions_create_list_end_via_http() {
1926        let (app, auth) = app_with_store().await;
1927        let ingest = bearer(&auth, "op", SESSION_INGEST);
1928        let read = bearer(&auth, "op", MEMORY_READ);
1929
1930        // Create two sessions.
1931        for (id, harness) in [("sess_a", "Pi"), ("sess_b", "Sakamoto")] {
1932            let res = app
1933                .clone()
1934                .oneshot(
1935                    Request::builder()
1936                        .method("POST")
1937                        .uri("/sessions")
1938                        .header("authorization", &ingest)
1939                        .header("content-type", "application/json")
1940                        .body(Body::from(
1941                            serde_json::json!({
1942                                "id": id,
1943                                "harness": harness,
1944                                "channel": "thread-1",
1945                                "started_at": "2026-07-05T10:00:00Z",
1946                            })
1947                            .to_string(),
1948                        ))
1949                        .unwrap(),
1950                )
1951                .await
1952                .unwrap();
1953            assert_eq!(res.status(), StatusCode::OK);
1954        }
1955
1956        // List — both present.
1957        let res = app
1958            .clone()
1959            .oneshot(
1960                Request::builder()
1961                    .uri("/sessions")
1962                    .header("authorization", &read)
1963                    .body(Body::empty())
1964                    .unwrap(),
1965            )
1966            .await
1967            .unwrap();
1968        assert_eq!(res.status(), StatusCode::OK);
1969        let body: serde_json::Value = serde_json::from_slice(
1970            &axum::body::to_bytes(res.into_body(), usize::MAX)
1971                .await
1972                .unwrap(),
1973        )
1974        .unwrap();
1975        let arr = body.as_array().unwrap();
1976        assert_eq!(arr.len(), 2);
1977
1978        // Filter by harness=pi.
1979        let res = app
1980            .clone()
1981            .oneshot(
1982                Request::builder()
1983                    .uri("/sessions?harness=pi")
1984                    .header("authorization", &read)
1985                    .body(Body::empty())
1986                    .unwrap(),
1987            )
1988            .await
1989            .unwrap();
1990        let body: serde_json::Value = serde_json::from_slice(
1991            &axum::body::to_bytes(res.into_body(), usize::MAX)
1992                .await
1993                .unwrap(),
1994        )
1995        .unwrap();
1996        assert_eq!(body.as_array().unwrap().len(), 1);
1997        assert_eq!(body[0]["harness"], "Pi");
1998
1999        // End sess_a.
2000        let res = app
2001            .clone()
2002            .oneshot(
2003                Request::builder()
2004                    .method("POST")
2005                    .uri("/sessions/sess_a/end")
2006                    .header("authorization", &ingest)
2007                    .header("content-type", "application/json")
2008                    .body(Body::from(
2009                        serde_json::json!({ "ended_at": "2026-07-05T11:00:00Z" }).to_string(),
2010                    ))
2011                    .unwrap(),
2012            )
2013            .await
2014            .unwrap();
2015        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2016
2017        // Verify ended_at is persisted.
2018        let res = app
2019            .oneshot(
2020                Request::builder()
2021                    .uri("/sessions?harness=pi")
2022                    .header("authorization", &read)
2023                    .body(Body::empty())
2024                    .unwrap(),
2025            )
2026            .await
2027            .unwrap();
2028        let body: serde_json::Value = serde_json::from_slice(
2029            &axum::body::to_bytes(res.into_body(), usize::MAX)
2030                .await
2031                .unwrap(),
2032        )
2033        .unwrap();
2034        assert_eq!(body[0]["ended_at"], "2026-07-05T11:00:00Z");
2035    }
2036
2037    #[tokio::test]
2038    async fn mining_queue_requires_review_capability() {
2039        let (app, auth) = app_with_store().await;
2040        let reviewer = bearer(&auth, "op", MINING_REVIEW);
2041        let reader = bearer(&auth, "op", MEMORY_READ);
2042
2043        // A memory:read holder cannot list the queue.
2044        let res = app
2045            .clone()
2046            .oneshot(
2047                Request::builder()
2048                    .uri("/mining/queue")
2049                    .header("authorization", &reader)
2050                    .body(Body::empty())
2051                    .unwrap(),
2052            )
2053            .await
2054            .unwrap();
2055        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2056
2057        // A mining:review holder can list (empty queue).
2058        let res = app
2059            .oneshot(
2060                Request::builder()
2061                    .uri("/mining/queue")
2062                    .header("authorization", &reviewer)
2063                    .body(Body::empty())
2064                    .unwrap(),
2065            )
2066            .await
2067            .unwrap();
2068        assert_eq!(res.status(), StatusCode::OK);
2069        let body: serde_json::Value = serde_json::from_slice(
2070            &axum::body::to_bytes(res.into_body(), usize::MAX)
2071                .await
2072                .unwrap(),
2073        )
2074        .unwrap();
2075        assert!(body.as_array().unwrap().is_empty());
2076    }
2077
2078    fn hit_mem(id: &str, sim: f32) -> SearchHit {
2079        SearchHit {
2080            memory: Memory {
2081                id: MemoryId(id.into()),
2082                content: id.into(),
2083                project: "p".into(),
2084                topic: "t".into(),
2085                source: ijima_core::MemorySource::Explicit,
2086                harness: ijima_core::harness::Harness::Pi,
2087                session_id: None,
2088                origin: ijima_core::InstanceId::local(),
2089                authority: ijima_core::AuthorityScope::local(),
2090                importance: 0.5,
2091                created_at: "0".into(),
2092            },
2093            similarity: sim,
2094        }
2095    }
2096
2097    #[test]
2098    fn merge_search_hits_ranks_desc_dedups_and_truncates() {
2099        // scope=visible merge: two ranked lists combine by similarity, dedup
2100        // by memory id (first wins), truncate to limit.
2101        let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.5)];
2102        let b = vec![hit_mem("c", 0.8), hit_mem("a", 0.7)]; // 'a' dup, lower sim
2103        let merged = merge_search_hits(a, b, 3);
2104        // Sorted by similarity desc: a(0.9), c(0.8), b(0.5) — the dup a(0.7)
2105        // is dropped (first wins).
2106        assert_eq!(merged.len(), 3);
2107        assert_eq!(merged[0].memory.id.0, "a");
2108        assert_eq!((merged[0].similarity * 10.0).round() as i32, 9);
2109        assert_eq!(merged[1].memory.id.0, "c");
2110        assert_eq!(merged[2].memory.id.0, "b");
2111    }
2112
2113    #[test]
2114    fn merge_search_hits_respects_limit() {
2115        let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.8)];
2116        let b = vec![hit_mem("c", 0.7), hit_mem("d", 0.6)];
2117        let merged = merge_search_hits(a, b, 2);
2118        assert_eq!(merged.len(), 2);
2119        assert_eq!(merged[0].memory.id.0, "a");
2120        assert_eq!(merged[1].memory.id.0, "b");
2121    }
2122
2123    #[cfg(feature = "mining")]
2124    #[tokio::test]
2125    async fn trigger_requires_mining_trigger_capability() {
2126        let (app, auth) = app_with_store().await;
2127        // A memory:write holder cannot trigger mining.
2128        let write = bearer(&auth, "elliott", MEMORY_WRITE);
2129        let res = app
2130            .oneshot(
2131                Request::builder()
2132                    .method("POST")
2133                    .uri("/sessions/sess_x/mine")
2134                    .header("authorization", &write)
2135                    .body(Body::empty())
2136                    .unwrap(),
2137            )
2138            .await
2139            .unwrap();
2140        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2141    }
2142
2143    #[cfg(feature = "mining")]
2144    #[tokio::test]
2145    async fn trigger_mines_decision_and_archives() {
2146        // Rules-only: assumes no IJIMA_LLM_* env is set (CI is clean). When
2147        // env is unset, `build_mining_agent` returns None and `mine_all` runs
2148        // the deterministic rules tier.
2149        let (app, auth) = app_with_store().await;
2150        let ingest = bearer(&auth, "elliott", SESSION_INGEST);
2151        let trigger = bearer(&auth, "elliott", MINING_TRIGGER);
2152
2153        // Ingest a decision-bearing turn into elliott's personal namespace.
2154        let turn = serde_json::json!({
2155            "session_id": "sess_mine",
2156            "turn_index": 0,
2157            "role": "User",
2158            "content": "We decided to use SurrealDB for storage.",
2159            "timestamp": "0",
2160        });
2161        let res = app
2162            .clone()
2163            .oneshot(
2164                Request::builder()
2165                    .method("POST")
2166                    .uri("/sessions/sess_mine/turns")
2167                    .header("authorization", &ingest)
2168                    .header("content-type", "application/json")
2169                    .body(Body::from(turn.to_string()))
2170                    .unwrap(),
2171            )
2172            .await
2173            .unwrap();
2174        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2175
2176        // Trigger mining (rules-only: no IJIMA_LLM_* env in tests).
2177        let res = app
2178            .oneshot(
2179                Request::builder()
2180                    .method("POST")
2181                    .uri("/sessions/sess_mine/mine")
2182                    .header("authorization", &trigger)
2183                    .body(Body::empty())
2184                    .unwrap(),
2185            )
2186            .await
2187            .unwrap();
2188        assert_eq!(res.status(), StatusCode::OK);
2189        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2190            .await
2191            .unwrap();
2192        let report: crate::mining_pipeline::MiningReport = serde_json::from_slice(&body).unwrap();
2193        assert!(
2194            report.archived >= 1,
2195            "rules tier should archive the decision: {report:?}"
2196        );
2197    }
2198
2199    // ===== Palace / diary / repo route tests (Phase B) =====
2200
2201    async fn body_json(res: axum::response::Response) -> serde_json::Value {
2202        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2203            .await
2204            .unwrap();
2205        serde_json::from_slice(&body).unwrap()
2206    }
2207
2208    async fn seed_memory(app: &Router, auth: &IjimaAuth, id: &str, project: &str, topic: &str) {
2209        let body = serde_json::json!({
2210            "id": id,
2211            "content": format!("{project}/{topic} note"),
2212            "project": project,
2213            "topic": topic,
2214            "source": "Explicit",
2215            "harness": "Pi",
2216            "session_id": "sess_1",
2217            "importance": 0.5,
2218            "created_at": "0",
2219        })
2220        .to_string();
2221        let res = app
2222            .clone()
2223            .oneshot(
2224                Request::builder()
2225                    .method("POST")
2226                    .uri("/memories")
2227                    .header("authorization", bearer(auth, "elliott", MEMORY_WRITE))
2228                    .header("content-type", "application/json")
2229                    .body(Body::from(body))
2230                    .unwrap(),
2231            )
2232            .await
2233            .unwrap();
2234        assert_eq!(res.status(), StatusCode::OK, "seed {id} failed");
2235    }
2236
2237    #[tokio::test]
2238    async fn rooms_taxonomy_stats_reflect_seeded_memories() {
2239        let (app, auth) = app_with_store().await;
2240        seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
2241        seed_memory(&app, &auth, "mem_b", "ijima", "auth").await;
2242        let read = bearer(&auth, "elliott", MEMORY_READ);
2243
2244        // /rooms
2245        let res = app
2246            .clone()
2247            .oneshot(
2248                Request::builder()
2249                    .uri("/rooms")
2250                    .header("authorization", &read)
2251                    .body(Body::empty())
2252                    .unwrap(),
2253            )
2254            .await
2255            .unwrap();
2256        assert_eq!(res.status(), StatusCode::OK);
2257        let rooms = body_json(res).await;
2258        let topics: std::collections::HashSet<&str> = rooms
2259            .as_array()
2260            .unwrap()
2261            .iter()
2262            .map(|r| r["topic"].as_str().unwrap())
2263            .collect();
2264        assert!(
2265            topics.contains("api") && topics.contains("auth"),
2266            "rooms: {rooms}"
2267        );
2268
2269        // /memories/stats
2270        let res = app
2271            .clone()
2272            .oneshot(
2273                Request::builder()
2274                    .uri("/memories/stats")
2275                    .header("authorization", &read)
2276                    .body(Body::empty())
2277                    .unwrap(),
2278            )
2279            .await
2280            .unwrap();
2281        assert_eq!(res.status(), StatusCode::OK);
2282        let stats = body_json(res).await;
2283        assert_eq!(stats["total"], 2, "stats: {stats}");
2284        assert_eq!(stats["projects"][0]["project"], "ijima");
2285        assert_eq!(stats["projects"][0]["count"], 2);
2286    }
2287
2288    #[tokio::test]
2289    async fn browse_memories_filters_by_project() {
2290        let (app, auth) = app_with_store().await;
2291        seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
2292        seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
2293        let read = bearer(&auth, "elliott", MEMORY_READ);
2294
2295        let res = app
2296            .clone()
2297            .oneshot(
2298                Request::builder()
2299                    .uri("/memories?project=possum")
2300                    .header("authorization", &read)
2301                    .body(Body::empty())
2302                    .unwrap(),
2303            )
2304            .await
2305            .unwrap();
2306        assert_eq!(res.status(), StatusCode::OK);
2307        let mems = body_json(res).await;
2308        let arr = mems.as_array().unwrap();
2309        assert_eq!(arr.len(), 1);
2310        assert_eq!(arr[0]["project"], "possum");
2311    }
2312
2313    #[tokio::test]
2314    async fn palace_graph_and_tunnel_link_shared_topic() {
2315        let (app, auth) = app_with_store().await;
2316        seed_memory(&app, &auth, "mem_a", "ijima", "efficiency").await;
2317        seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
2318        let read = bearer(&auth, "elliott", MEMORY_READ);
2319
2320        let res = app
2321            .clone()
2322            .oneshot(
2323                Request::builder()
2324                    .uri("/palace/graph")
2325                    .header("authorization", &read)
2326                    .body(Body::empty())
2327                    .unwrap(),
2328            )
2329            .await
2330            .unwrap();
2331        assert_eq!(res.status(), StatusCode::OK);
2332        let graph = body_json(res).await;
2333        let projects: std::collections::HashSet<&str> = graph["projects"]
2334            .as_array()
2335            .unwrap()
2336            .iter()
2337            .map(|p| p.as_str().unwrap())
2338            .collect();
2339        assert!(
2340            projects.contains("ijima") && projects.contains("possum"),
2341            "graph: {graph}"
2342        );
2343
2344        let res = app
2345            .clone()
2346            .oneshot(
2347                Request::builder()
2348                    .uri("/palace/tunnel?topic=efficiency&project_a=ijima&project_b=possum")
2349                    .header("authorization", &read)
2350                    .body(Body::empty())
2351                    .unwrap(),
2352            )
2353            .await
2354            .unwrap();
2355        assert_eq!(res.status(), StatusCode::OK);
2356        let trav = body_json(res).await;
2357        assert_eq!(trav["memories_a"].as_array().unwrap().len(), 1);
2358        assert_eq!(trav["memories_b"].as_array().unwrap().len(), 1);
2359    }
2360
2361    #[tokio::test]
2362    async fn diary_write_then_read_round_trips() {
2363        let (app, auth) = app_with_store().await;
2364        let write = bearer(&auth, "elliott", MEMORY_WRITE);
2365        let read = bearer(&auth, "elliott", MEMORY_READ);
2366
2367        let body = serde_json::json!({
2368            "agent": "pi",
2369            "content": "shipped the routes",
2370            "topic": "ijima",
2371            "timestamp": "2026-08-09T12:00:00Z"
2372        })
2373        .to_string();
2374        let res = app
2375            .clone()
2376            .oneshot(
2377                Request::builder()
2378                    .method("POST")
2379                    .uri("/diaries")
2380                    .header("authorization", &write)
2381                    .header("content-type", "application/json")
2382                    .body(Body::from(body))
2383                    .unwrap(),
2384            )
2385            .await
2386            .unwrap();
2387        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2388
2389        let res = app
2390            .clone()
2391            .oneshot(
2392                Request::builder()
2393                    .uri("/diaries/pi")
2394                    .header("authorization", &read)
2395                    .body(Body::empty())
2396                    .unwrap(),
2397            )
2398            .await
2399            .unwrap();
2400        assert_eq!(res.status(), StatusCode::OK);
2401        let entries = body_json(res).await;
2402        let arr = entries.as_array().unwrap();
2403        assert_eq!(arr.len(), 1);
2404        assert_eq!(arr[0]["content"], "shipped the routes");
2405    }
2406
2407    #[tokio::test]
2408    async fn diary_write_requires_memory_write_not_read() {
2409        let (app, auth) = app_with_store().await;
2410        let read = bearer(&auth, "elliott", MEMORY_READ);
2411        let body = serde_json::json!({"agent": "pi", "content": "x", "timestamp": "t"}).to_string();
2412        let res = app
2413            .clone()
2414            .oneshot(
2415                Request::builder()
2416                    .method("POST")
2417                    .uri("/diaries")
2418                    .header("authorization", &read)
2419                    .header("content-type", "application/json")
2420                    .body(Body::from(body))
2421                    .unwrap(),
2422            )
2423            .await
2424            .unwrap();
2425        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2426    }
2427
2428    #[tokio::test]
2429    async fn repo_register_list_resolve_round_trips() {
2430        let (app, auth) = app_with_store().await;
2431        let admin = bearer(&auth, "elliott", ADMIN);
2432        let read = bearer(&auth, "elliott", MEMORY_READ);
2433
2434        // register a repo (admin)
2435        let body = serde_json::json!({
2436            "name": "Ijima",
2437            "path": "/home/x/Ijima",
2438            "remote_url": "git@github.com:Industrial-Algebra/Ijima.git",
2439            "role": "memory-service"
2440        })
2441        .to_string();
2442        let res = app
2443            .clone()
2444            .oneshot(
2445                Request::builder()
2446                    .method("POST")
2447                    .uri("/repos")
2448                    .header("authorization", &admin)
2449                    .header("content-type", "application/json")
2450                    .body(Body::from(body))
2451                    .unwrap(),
2452            )
2453            .await
2454            .unwrap();
2455        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2456
2457        // list (memory:read)
2458        let res = app
2459            .clone()
2460            .oneshot(
2461                Request::builder()
2462                    .uri("/repos")
2463                    .header("authorization", &read)
2464                    .body(Body::empty())
2465                    .unwrap(),
2466            )
2467            .await
2468            .unwrap();
2469        assert_eq!(res.status(), StatusCode::OK);
2470        let repos = body_json(res).await;
2471        assert_eq!(repos[0]["name"], "Ijima");
2472        assert_eq!(repos[0]["path"], "/home/x/Ijima");
2473
2474        // resolve a cwd inside the repo (memory:read)
2475        let res = app
2476            .clone()
2477            .oneshot(
2478                Request::builder()
2479                    .uri("/repos/resolve?cwd=/home/x/Ijima/src")
2480                    .header("authorization", &read)
2481                    .body(Body::empty())
2482                    .unwrap(),
2483            )
2484            .await
2485            .unwrap();
2486        assert_eq!(res.status(), StatusCode::OK);
2487        let repo = body_json(res).await;
2488        assert_eq!(repo["name"], "Ijima");
2489
2490        // resolve a cwd in no registered repo → 404
2491        let res = app
2492            .clone()
2493            .oneshot(
2494                Request::builder()
2495                    .uri("/repos/resolve?cwd=/nowhere/here")
2496                    .header("authorization", &read)
2497                    .body(Body::empty())
2498                    .unwrap(),
2499            )
2500            .await
2501            .unwrap();
2502        assert_eq!(res.status(), StatusCode::NOT_FOUND);
2503    }
2504
2505    #[tokio::test]
2506    async fn repo_register_requires_admin() {
2507        let (app, auth) = app_with_store().await;
2508        let read = bearer(&auth, "elliott", MEMORY_READ);
2509        let body = serde_json::json!({
2510            "name": "X", "path": "/x", "remote_url": "u", "role": "r"
2511        })
2512        .to_string();
2513        let res = app
2514            .clone()
2515            .oneshot(
2516                Request::builder()
2517                    .method("POST")
2518                    .uri("/repos")
2519                    .header("authorization", &read)
2520                    .header("content-type", "application/json")
2521                    .body(Body::from(body))
2522                    .unwrap(),
2523            )
2524            .await
2525            .unwrap();
2526        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2527    }
2528}