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, TokenRevocation, TunnelTraversal,
48    capabilities::{
49        ADMIN, KNOWLEDGE_READ, MEMORY_READ, MEMORY_WRITE, MINING_REVIEW, SESSION_INGEST,
50        TRUST_PROMOTE,
51    },
52    harness::Harness,
53    memory::MemorySource,
54};
55
56use crate::extractor::AuthPrincipal;
57use crate::redaction::Redactor;
58
59#[cfg(feature = "federation")]
60use ijima_core::federation::{
61    AuthoritativeScope, ConflictSignal, FederationState, InstanceFederationConfig, RoutedWrite,
62    RoutedWriteReceipt,
63};
64
65/// Builds the Ijima HTTP application router.
66///
67/// `auth` and `store` are shared via axum's [`Extension`] layer; the
68/// [`AuthPrincipal`] extractor reads `auth` to verify bearer tokens.
69pub fn app(
70    auth: Arc<crate::IjimaAuth>,
71    store: Arc<dyn Store>,
72    kg: Arc<dyn KnowledgeGraph>,
73    embedder: Option<Arc<dyn Embedder>>,
74    redactor: Arc<Redactor>,
75    #[cfg(feature = "rate-limit")] rate_limiter: Option<crate::rate_limit::RateLimitState>,
76    #[cfg(feature = "federation")] federation_config: Arc<InstanceFederationConfig>,
77) -> Router {
78    let router = Router::new()
79        .route("/health", get(health))
80        .route("/status", get(status))
81        .route("/memories", get(browse_memories).post(store_memory))
82        .route("/memories/check", post(check_duplicate))
83        .route("/memories/search", post(search_memories))
84        .route("/memories/stats", get(memory_stats))
85        .route("/memories/{id}", get(recall_memory).delete(delete_memory))
86        .route("/memories/{id}/promote", post(promote_memory))
87        .route("/rooms", get(list_rooms))
88        .route("/taxonomy", get(taxonomy))
89        .route("/palace/graph", get(palace_graph))
90        .route("/palace/tunnel", get(traverse_tunnel))
91        .route("/diaries", post(write_diary))
92        .route("/diaries/{agent}", get(read_diary))
93        .route("/repos", get(list_repos).post(register_repo))
94        .route("/repos/resolve", get(resolve_repo))
95        .route("/tokens/revoke", post(revoke_token_route))
96        .route("/tokens/revocations", get(list_token_revocations))
97        .route("/namespaces/grant", post(grant_ns_membership))
98        .route("/namespaces/revoke", post(revoke_ns_membership))
99        .route("/namespaces/members", get(list_ns_members))
100        .route("/doctrine", post(ingest_doctrine))
101        .route("/wakeup", get(wakeup))
102        .route("/kg/triples", post(add_triple).get(find_triples))
103        .route("/kg/entities/{id}", get(query_entity))
104        .route("/kg/triples/{id}/invalidate", post(invalidate_triple))
105        .route("/kg/timeline", get(kg_timeline))
106        .route("/kg/stats", get(kg_stats))
107        .route(
108            "/sessions/{session_id}/turns",
109            post(ingest_turn).get(session_turns),
110        )
111        .route("/sessions", post(create_session).get(list_sessions))
112        .route("/sessions/{session_id}/end", post(end_session))
113        .route("/mining/queue", get(list_pending))
114        .route("/mining/queue/{id}/accept", post(accept_extraction))
115        .route("/mining/queue/{id}/reject", post(reject_extraction));
116    #[cfg(feature = "mining")]
117    let router = router.route("/sessions/{session_id}/mine", post(trigger_mine));
118
119    #[cfg(feature = "federation")]
120    let router = router
121        .route("/federation/state", get(federation_state))
122        .route("/federation/routed-write", post(routed_write))
123        .route("/federation/conflict-signal", post(conflict_signal));
124    let router = router
125        .layer(Extension(auth))
126        .layer(Extension(store))
127        .layer(Extension(kg))
128        .layer(Extension(embedder))
129        .layer(Extension(redactor));
130
131    #[cfg(feature = "federation")]
132    let router = router.layer(Extension(federation_config));
133
134    #[cfg(feature = "rate-limit")]
135    let router = match rate_limiter {
136        Some(rl) => router.layer(Extension(rl)),
137        None => router,
138    };
139    #[cfg(not(feature = "rate-limit"))]
140    let router = router;
141
142    router
143}
144
145// ---------- errors ----------
146
147/// API-level error mapping to HTTP status codes.
148#[derive(Debug)]
149pub enum ApiError {
150    /// Capability check failed (principal's token lacks the required cap).
151    Forbidden,
152    /// Resource absent (or in a different namespace).
153    NotFound,
154    /// Malformed request body or parameters.
155    BadRequest(String),
156    /// Duplicate content (content-hash dedup) — 409.
157    Conflict(String),
158    /// Store / internal failure.
159    Internal(String),
160}
161
162impl IntoResponse for ApiError {
163    fn into_response(self) -> Response {
164        let (status, msg): (StatusCode, String) = match self {
165            ApiError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".into()),
166            ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
167            ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
168            ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
169            ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
170        };
171        (status, msg).into_response()
172    }
173}
174
175fn internal(e: ijima_core::IjimaError) -> ApiError {
176    match e {
177        ijima_core::IjimaError::Duplicate { detail } => ApiError::Conflict(detail),
178        other => ApiError::Internal(other.to_string()),
179    }
180}
181
182/// Query params carrying an optional namespace override + limit.
183#[derive(Deserialize, Default)]
184struct NsQuery {
185    /// Override the default personal namespace. Personal namespaces
186    /// (`ns_<name>_private`) belonging to *other* principals are
187    /// rejected with 403; shared/global namespaces are allowed.
188    namespace: Option<String>,
189    limit: Option<usize>,
190}
191
192/// Resolves the effective namespace for a request: the caller's
193/// personal namespace by default, or the requested one if authorized.
194///
195/// Authorization (WS3 org walls, in check order):
196/// - `ns_<this_principal>_private` → allowed (own personal).
197/// - any other `*_private` → **403** (someone else's personal).
198/// - `global`, `ns_doctrine` (doctrine), `ns_import_*` (staging) → open
199///   to any authenticated principal (the commons/read-everyone tiers).
200/// - anything else (shared org namespaces, e.g. `ns_ia_shared`) →
201///   **membership-gated**: the store's membership table must contain the
202///   principal, or the grant must carry `admin` (operator bypass).
203async fn resolve_ns(
204    principal: &AuthPrincipal,
205    store: &dyn Store,
206    requested: Option<&str>,
207) -> Result<ijima_core::NamespaceId, ApiError> {
208    let own = format!("ns_{}_private", principal.0.principal.as_str());
209    let requested = match requested {
210        None => return Ok(ijima_core::NamespaceId::new(own)),
211        Some(ns) => ns,
212    };
213    if requested == own {
214        return Ok(ijima_core::NamespaceId::new(requested));
215    }
216    if requested.ends_with("_private") {
217        return Err(ApiError::Forbidden);
218    }
219    let open = requested == "global"
220        || requested == ijima_core::namespace::DOCTRINE_NAMESPACE
221        || requested.starts_with("ns_import_");
222    if !open && !principal.0.may(ADMIN) {
223        let ns = ijima_core::NamespaceId::new(requested);
224        let member = store
225            .is_namespace_member(&ns, principal.0.principal.as_str())
226            .await
227            .map_err(internal)?;
228        if !member {
229            return Err(ApiError::Forbidden);
230        }
231    }
232    Ok(ijima_core::NamespaceId::new(requested))
233}
234
235// ---------- handlers ----------
236
237async fn health() -> impl IntoResponse {
238    Json(serde_json::json!({ "status": "ok" }))
239}
240
241/// Process start marker — captured once, when `/status` is first hit
242/// (equivalently: daemon boot, since the router is built at boot).
243static STARTED_AT: std::sync::OnceLock<std::time::SystemTime> = std::sync::OnceLock::new();
244
245#[derive(Serialize)]
246struct StatusResponse {
247    memories: usize,
248    namespaces: Vec<NamespaceCount>,
249    entities: usize,
250    triples: usize,
251    /// Server version (crate version at compile time).
252    version: &'static str,
253    /// Wall-clock process start (unix seconds).
254    started_at_unix: u64,
255    /// Seconds since process start.
256    uptime_secs: u64,
257}
258
259/// Global store statistics across all namespaces. Admin-gated (it spans
260/// every principal's data). Per-namespace KG counts are available via
261/// `GET /kg/stats?namespace=...`.
262async fn status(
263    principal: AuthPrincipal,
264    Extension(store): Extension<Arc<dyn Store>>,
265    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
266) -> Result<Json<StatusResponse>, ApiError> {
267    if !principal.0.may(ijima_core::capabilities::ADMIN) {
268        return Err(ApiError::Forbidden);
269    }
270    let store_stats = store.store_stats().await.map_err(internal)?;
271    let kg_stats = kg.kg_global_stats().await.map_err(internal)?;
272    let started = *STARTED_AT.get_or_init(std::time::SystemTime::now);
273    let uptime_secs = started.elapsed().map(|d| d.as_secs()).unwrap_or(0);
274    let started_at_unix = started
275        .duration_since(std::time::UNIX_EPOCH)
276        .map(|d| d.as_secs())
277        .unwrap_or(0);
278    Ok(Json(StatusResponse {
279        memories: store_stats.total_memories,
280        namespaces: store_stats.namespaces,
281        entities: kg_stats.entities,
282        triples: kg_stats.triples,
283        version: env!("CARGO_PKG_VERSION"),
284        started_at_unix,
285        uptime_secs,
286    }))
287}
288
289// ===== Token revocation (WS1b — grant kill-switch) =====
290
291/// Body for `POST /tokens/revoke`.
292#[derive(Deserialize)]
293struct RevokeRequest {
294    /// The bearer token to revoke (the raw string; only its SHA-256 is
295    /// persisted).
296    token: String,
297    /// Optional operator note (e.g. `"leaked in CI log"`).
298    reason: Option<String>,
299}
300
301/// Revokes a grant token: persists the hash (survives restarts) and adds
302/// it to the live rejection set. Auth: `admin`. Idempotent.
303async fn revoke_token_route(
304    principal: AuthPrincipal,
305    Extension(store): Extension<Arc<dyn Store>>,
306    Extension(auth): Extension<Arc<crate::IjimaAuth>>,
307    Json(req): Json<RevokeRequest>,
308) -> Result<StatusCode, ApiError> {
309    if !principal.0.may(ADMIN) {
310        return Err(ApiError::Forbidden);
311    }
312    let revocation = TokenRevocation {
313        token_hash: crate::auth::bearer_hash(&req.token),
314        revoked_at_unix: std::time::SystemTime::now()
315            .duration_since(std::time::UNIX_EPOCH)
316            .map(|d| d.as_secs())
317            .unwrap_or(0),
318        reason: req.reason,
319    };
320    // Persist first, then arm the in-memory check: a crash between the two
321    // re-arms at boot (store is source of truth).
322    store
323        .revoke_token(revocation.clone())
324        .await
325        .map_err(internal)?;
326    auth.revoke(&revocation.token_hash);
327    Ok(StatusCode::NO_CONTENT)
328}
329
330// ---------- namespace membership (WS3 org walls) ----------
331
332#[derive(Deserialize)]
333struct NsMembershipRequest {
334    /// The shared namespace (e.g. `ns_ia_shared`).
335    namespace: String,
336    /// The principal to grant/revoke.
337    principal: String,
338}
339
340/// Grants namespace membership (upsert). Auth: `admin`. Powers
341/// `ijima namespace grant`.
342async fn grant_ns_membership(
343    principal: AuthPrincipal,
344    Extension(store): Extension<Arc<dyn Store>>,
345    Json(req): Json<NsMembershipRequest>,
346) -> Result<Json<serde_json::Value>, ApiError> {
347    if !principal.0.may(ADMIN) {
348        return Err(ApiError::Forbidden);
349    }
350    let membership = ijima_core::NamespaceMembership {
351        namespace: req.namespace.clone(),
352        principal: req.principal.clone(),
353        granted_at_unix: std::time::SystemTime::now()
354            .duration_since(std::time::UNIX_EPOCH)
355            .map(|d| d.as_secs())
356            .unwrap_or(0),
357        granted_by: principal.0.principal.as_str().to_string(),
358    };
359    store
360        .grant_namespace_membership(membership)
361        .await
362        .map_err(internal)?;
363    Ok(Json(
364        serde_json::json!({ "granted": true, "namespace": req.namespace, "principal": req.principal }),
365    ))
366}
367
368/// Revokes namespace membership (idempotent). Auth: `admin`.
369async fn revoke_ns_membership(
370    principal: AuthPrincipal,
371    Extension(store): Extension<Arc<dyn Store>>,
372    Json(req): Json<NsMembershipRequest>,
373) -> Result<StatusCode, ApiError> {
374    if !principal.0.may(ADMIN) {
375        return Err(ApiError::Forbidden);
376    }
377    store
378        .revoke_namespace_membership(&NamespaceId::new(&req.namespace), &req.principal)
379        .await
380        .map_err(internal)?;
381    Ok(StatusCode::NO_CONTENT)
382}
383
384/// Lists a namespace's members, oldest grant first. Auth: `admin`.
385async fn list_ns_members(
386    principal: AuthPrincipal,
387    Extension(store): Extension<Arc<dyn Store>>,
388    Query(q): Query<NsQuery>,
389) -> Result<Json<Vec<ijima_core::NamespaceMembership>>, ApiError> {
390    if !principal.0.may(ADMIN) {
391        return Err(ApiError::Forbidden);
392    }
393    let ns = q.namespace.as_deref().ok_or(ApiError::BadRequest(
394        "?namespace=<ns> is required".to_string(),
395    ))?;
396    let members = store
397        .list_namespace_members(&NamespaceId::new(ns))
398        .await
399        .map_err(internal)?;
400    Ok(Json(members))
401}
402
403/// Lists every recorded revocation, oldest first. Auth: `admin`.
404async fn list_token_revocations(
405    principal: AuthPrincipal,
406    Extension(store): Extension<Arc<dyn Store>>,
407) -> Result<Json<Vec<TokenRevocation>>, ApiError> {
408    if !principal.0.may(ADMIN) {
409        return Err(ApiError::Forbidden);
410    }
411    Ok(Json(store.list_revocations().await.map_err(internal)?))
412}
413
414#[derive(Serialize)]
415struct IdResponse {
416    id: String,
417}
418
419async fn store_memory(
420    principal: AuthPrincipal,
421    Extension(store): Extension<Arc<dyn Store>>,
422    Query(q): Query<NsQuery>,
423    Json(memory): Json<Memory>,
424) -> Result<Json<IdResponse>, ApiError> {
425    if !principal.0.may(MEMORY_WRITE) {
426        return Err(ApiError::Forbidden);
427    }
428    // WS2: `?namespace=` routes the write into a shared/import namespace
429    // (grant-checked by resolve_ns — another principal's `_private` is
430    // still forbidden); without it, the caller's personal namespace.
431    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
432    let mut memory = memory;
433    if memory.created_at.is_empty() {
434        memory.created_at = std::time::SystemTime::now()
435            .duration_since(std::time::UNIX_EPOCH)
436            .map(|d| d.as_secs().to_string())
437            .unwrap_or_default();
438    }
439    let id = store.store_memory(&ns, memory).await.map_err(internal)?;
440    Ok(Json(IdResponse { id: id.0 }))
441}
442
443#[derive(Deserialize)]
444struct CheckDuplicateRequest {
445    content: String,
446}
447
448#[derive(Serialize)]
449struct CheckDuplicateResponse {
450    /// The id of an existing memory with identical content, if any.
451    duplicate: Option<String>,
452}
453
454/// Pre-check for content-hash dedup (`POST /memories/check`). Returns
455/// the existing memory id if identical content is already stored in the
456/// caller's (effective) namespace.
457async fn check_duplicate(
458    principal: AuthPrincipal,
459    Extension(store): Extension<Arc<dyn Store>>,
460    Query(q): Query<NsQuery>,
461    Json(req): Json<CheckDuplicateRequest>,
462) -> Result<Json<CheckDuplicateResponse>, ApiError> {
463    if !principal.0.may(MEMORY_READ) {
464        return Err(ApiError::Forbidden);
465    }
466    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
467    let dup = store
468        .check_duplicate(&ns, &req.content)
469        .await
470        .map_err(internal)?;
471    Ok(Json(CheckDuplicateResponse {
472        duplicate: dup.map(|id| id.0),
473    }))
474}
475
476async fn recall_memory(
477    principal: AuthPrincipal,
478    Extension(store): Extension<Arc<dyn Store>>,
479    Path(id): Path<String>,
480    Query(q): Query<NsQuery>,
481) -> Result<Json<Memory>, ApiError> {
482    if !principal.0.may(MEMORY_READ) {
483        return Err(ApiError::Forbidden);
484    }
485    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
486    match store
487        .recall_memory(&ns, &MemoryId(id))
488        .await
489        .map_err(internal)?
490    {
491        Some(memory) => Ok(Json(memory)),
492        None => Err(ApiError::NotFound),
493    }
494}
495
496async fn delete_memory(
497    principal: AuthPrincipal,
498    Extension(store): Extension<Arc<dyn Store>>,
499    Path(id): Path<String>,
500    Query(q): Query<NsQuery>,
501) -> Result<StatusCode, ApiError> {
502    if !principal.0.may(MEMORY_WRITE) {
503        return Err(ApiError::Forbidden);
504    }
505    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
506    store
507        .delete_memory(&ns, &MemoryId(id))
508        .await
509        .map_err(internal)?;
510    Ok(StatusCode::NO_CONTENT)
511}
512
513#[derive(Deserialize)]
514struct SearchRequest {
515    /// The query text. The daemon embeds this centrally with its own
516    /// embedder (D9 §5: "the service owns the model"), guaranteeing
517    /// vector compatibility with stored memories.
518    text: String,
519    limit: Option<usize>,
520    /// Search scope: `personal` (default — the resolved namespace only) or
521    /// `visible` (the principal's private namespace + the `global` commons,
522    /// merged by similarity). The pi integration uses `visible` for parity
523    /// with pi-mempalace's global search.
524    scope: Option<String>,
525}
526
527#[derive(Serialize)]
528struct SearchResponse {
529    memories: Vec<SearchHit>,
530}
531
532async fn search_memories(
533    principal: AuthPrincipal,
534    Extension(store): Extension<Arc<dyn Store>>,
535    Extension(embedder): Extension<Option<Arc<dyn Embedder>>>,
536    Query(q): Query<NsQuery>,
537    Json(req): Json<SearchRequest>,
538) -> Result<Json<SearchResponse>, ApiError> {
539    if !principal.0.may(MEMORY_READ) {
540        return Err(ApiError::Forbidden);
541    }
542    let embedder = embedder
543        .ok_or_else(|| ApiError::Internal("search unavailable: daemon has no embedder".into()))?;
544    let query = embedder.embed(&req.text).map_err(internal)?;
545    let limit = req.limit.unwrap_or(10);
546
547    // `visible` scope: merge the principal's private namespace + the global
548    // commons, ranked by similarity across both (pi-mempalace parity). The
549    // `personal` default searches only the resolved namespace.
550    let hits = if req.scope.as_deref() == Some("visible") {
551        let own_ns = principal.0.personal_namespace();
552        let global_ns = NamespaceId::new("global");
553        let own_hits = store
554            .search_memories(&own_ns, &query, limit)
555            .await
556            .map_err(internal)?;
557        let global_hits = if own_ns == global_ns {
558            Vec::new()
559        } else {
560            store
561                .search_memories(&global_ns, &query, limit)
562                .await
563                .map_err(internal)?
564        };
565        merge_search_hits(own_hits, global_hits, limit)
566    } else {
567        let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
568        store
569            .search_memories(&ns, &query, limit)
570            .await
571            .map_err(internal)?
572    };
573    Ok(Json(SearchResponse { memories: hits }))
574}
575
576/// Merges two scored hit lists by similarity (desc), deduplicating by memory
577/// id (the highest-similarity instance wins — NOT `dedup_by`, which only
578/// drops adjacent dups) and truncating to `limit`. Pure — the `scope=visible`
579/// path uses this to combine private + global results.
580fn merge_search_hits(a: Vec<SearchHit>, b: Vec<SearchHit>, limit: usize) -> Vec<SearchHit> {
581    use std::collections::HashSet;
582    let mut all: Vec<SearchHit> = a.into_iter().chain(b).collect();
583    all.sort_by(|x, y| {
584        y.similarity
585            .partial_cmp(&x.similarity)
586            .unwrap_or(std::cmp::Ordering::Equal)
587    });
588    // Keep the first (highest-similarity, post-sort) instance of each id.
589    let mut seen: HashSet<String> = HashSet::new();
590    all.retain(|h| seen.insert(h.memory.id.0.clone()));
591    all.truncate(limit);
592    all
593}
594
595// ---------- promotion (personal → shared, D9 §2) ----------
596
597#[derive(Deserialize)]
598struct PromoteRequest {
599    /// The shared/team namespace to promote into
600    /// (e.g. `ns_team_default`).
601    target_namespace: String,
602    /// Optional id for the promoted copy. Defaults to
603    /// `<original_id>__shared`.
604    new_id: Option<String>,
605}
606
607#[derive(Serialize)]
608struct PromoteResponse {
609    id: String,
610    original_id: String,
611    target_namespace: String,
612    redactions: Vec<crate::redaction::Redaction>,
613}
614
615/// Promotes a memory from the caller's personal namespace to a shared
616/// namespace, running the [redaction filter](crate::redaction) at the
617/// boundary. The original stays verbatim in personal scope; a scrubbed
618/// copy lands in the target namespace.
619async fn promote_memory(
620    principal: AuthPrincipal,
621    Extension(store): Extension<Arc<dyn Store>>,
622    Extension(redactor): Extension<Arc<Redactor>>,
623    Path(id): Path<String>,
624    Json(req): Json<PromoteRequest>,
625) -> Result<Json<PromoteResponse>, ApiError> {
626    if !principal.0.may(TRUST_PROMOTE) {
627        return Err(ApiError::Forbidden);
628    }
629    let personal_ns = principal.0.personal_namespace();
630
631    // Read from the caller's personal namespace.
632    let memory = store
633        .recall_memory(&personal_ns, &MemoryId(id.clone()))
634        .await
635        .map_err(internal)?
636        .ok_or(ApiError::NotFound)?;
637
638    // Scrub at the boundary (D9 §2 — the one place filtering happens).
639    let scrubbed = redactor.redact(&memory.content);
640
641    // Write the redacted copy to the shared namespace.
642    let new_id = req
643        .new_id
644        .clone()
645        .unwrap_or_else(|| format!("{id}__shared"));
646    let promoted = Memory {
647        id: MemoryId(new_id.clone()),
648        content: scrubbed.text,
649        project: memory.project,
650        topic: memory.topic,
651        source: ijima_core::memory::MemorySource::Explicit,
652        harness: memory.harness,
653        // Provenance back-reference to the original personal memory.
654        session_id: Some(id.clone()),
655        // Promotion preserves the origin/authority provenance of the source.
656        origin: memory.origin.clone(),
657        authority: memory.authority.clone(),
658        importance: memory.importance,
659        created_at: memory.created_at.clone(),
660    };
661    let target_ns = ijima_core::NamespaceId::new(&req.target_namespace);
662    // WS3: the promotion target goes through the same org-wall rule as
663    // every other write — membership for shared namespaces (admin
664    // bypasses); import staging is not a valid promotion target.
665    {
666        let target = req.target_namespace.as_str();
667        if target.ends_with("_private") && target != personal_ns.as_str() {
668            return Err(ApiError::Forbidden);
669        }
670        let open = target == "global"
671            || target == ijima_core::namespace::DOCTRINE_NAMESPACE
672            || target == personal_ns.as_str();
673        if target.starts_with("ns_import_") {
674            return Err(ApiError::BadRequest(
675                "import staging namespaces are not promotion targets".to_string(),
676            ));
677        }
678        if !open && !principal.0.may(ADMIN) {
679            let member = store
680                .is_namespace_member(&target_ns, principal.0.principal.as_str())
681                .await
682                .map_err(internal)?;
683            if !member {
684                return Err(ApiError::Forbidden);
685            }
686        }
687    }
688    store
689        .store_memory(&target_ns, promoted)
690        .await
691        .map_err(internal)?;
692
693    Ok(Json(PromoteResponse {
694        id: new_id,
695        original_id: id,
696        target_namespace: req.target_namespace,
697        redactions: scrubbed.redactions,
698    }))
699}
700
701// ---------- doctrine ingest (D9) ----------
702
703#[derive(Deserialize)]
704struct DoctrineRequest {
705    id: String,
706    content: String,
707    project: String,
708    topic: String,
709}
710
711/// Ingests a curated doctrine entry into the global `ns_doctrine`
712/// namespace. Admin-gated — doctrine is PR-reviewed in Git and never
713/// written by agents. Idempotent (delete-then-store) so re-ingests
714/// upsert cleanly. No redaction (doctrine is pre-reviewed).
715async fn ingest_doctrine(
716    principal: AuthPrincipal,
717    Extension(store): Extension<Arc<dyn Store>>,
718    Json(req): Json<DoctrineRequest>,
719) -> Result<Json<IdResponse>, ApiError> {
720    if !principal.0.may(ijima_core::capabilities::ADMIN) {
721        return Err(ApiError::Forbidden);
722    }
723    let ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
724    // Idempotent upsert: remove any existing entry, then store.
725    store
726        .delete_memory(&ns, &MemoryId(req.id.clone()))
727        .await
728        .map_err(internal)?;
729    let memory = Memory {
730        id: MemoryId(req.id.clone()),
731        content: req.content,
732        project: req.project,
733        topic: req.topic,
734        source: ijima_core::memory::MemorySource::Doctrine,
735        harness: ijima_core::harness::Harness::Other,
736        session_id: None,
737        // Doctrine is the curated local tier — authoritative on this instance.
738        origin: ijima_core::InstanceId::local(),
739        authority: ijima_core::AuthorityScope::local(),
740        importance: 1.0,
741        created_at: std::time::SystemTime::now()
742            .duration_since(std::time::UNIX_EPOCH)
743            .map(|d| d.as_secs().to_string())
744            .unwrap_or_default(),
745    };
746    store.store_memory(&ns, memory).await.map_err(internal)?;
747    Ok(Json(IdResponse { id: req.id }))
748}
749
750// ---------- wake-up composition (D9 §4) ----------
751
752/// How many personal essentials to include in a wake-up response.
753const WAKEUP_PERSONAL_LIMIT: usize = 20;
754/// How many doctrine entries to include.
755const WAKEUP_DOCTRINE_LIMIT: usize = 50;
756
757#[derive(Serialize)]
758struct WakeupResponse {
759    /// L0: the authenticated principal's identity.
760    identity: serde_json::Value,
761    /// L1a: the caller's personal essentials (top-N by importance + recency).
762    personal_essentials: Vec<Memory>,
763    /// L1b: the shared team doctrine baseline (identical across the team).
764    doctrine: Vec<Memory>,
765}
766
767/// Composes the session-start context: L0 identity + L1a personal
768/// essentials + L1b team doctrine. This is the "shared brain" — L1b is
769/// identical across the team, L1a is the individual's personal brain.
770async fn wakeup(
771    principal: AuthPrincipal,
772    Extension(store): Extension<Arc<dyn Store>>,
773) -> Result<Json<WakeupResponse>, ApiError> {
774    if !principal.0.may(MEMORY_READ) {
775        return Err(ApiError::Forbidden);
776    }
777    let personal_ns = principal.0.personal_namespace();
778    let doctrine_ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
779
780    let (personal_essentials, doctrine) = tokio::join!(
781        store.list_memories(&personal_ns, WAKEUP_PERSONAL_LIMIT),
782        store.list_memories(&doctrine_ns, WAKEUP_DOCTRINE_LIMIT),
783    );
784
785    Ok(Json(WakeupResponse {
786        identity: serde_json::json!({ "principal": principal.0.principal.as_str() }),
787        personal_essentials: personal_essentials.map_err(internal)?,
788        doctrine: doctrine.map_err(internal)?,
789    }))
790}
791
792// ---------- knowledge graph ----------
793
794#[derive(Deserialize)]
795struct AddTripleRequest {
796    subject: String,
797    predicate: String,
798    object: String,
799    valid_from: Option<String>,
800    confidence: Option<f32>,
801    source_memory_id: Option<String>,
802}
803
804async fn add_triple(
805    principal: AuthPrincipal,
806    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
807    Extension(store): Extension<Arc<dyn Store>>,
808    Json(req): Json<AddTripleRequest>,
809) -> Result<Json<ijima_core::Triple>, ApiError> {
810    if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
811        return Err(ApiError::Forbidden);
812    }
813    let ns = resolve_ns(&principal, store.as_ref(), None).await?;
814    let triple = kg
815        .add_triple(
816            &ns,
817            EntityId::new(req.subject),
818            &req.predicate,
819            EntityId::new(req.object),
820            req.valid_from.as_deref(),
821            req.confidence.unwrap_or(1.0),
822            req.source_memory_id.as_deref(),
823        )
824        .await
825        .map_err(internal)?;
826    // Touch `store` so the Extension is consumed.
827    let _ = store;
828    Ok(Json(triple))
829}
830
831async fn query_entity(
832    principal: AuthPrincipal,
833    Extension(store): Extension<Arc<dyn Store>>,
834    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
835    Path(id): Path<String>,
836    Query(q): Query<NsQuery>,
837) -> Result<Json<ijima_core::EntityRecord>, ApiError> {
838    if !principal.0.may(KNOWLEDGE_READ) {
839        return Err(ApiError::Forbidden);
840    }
841    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
842    let rec = kg
843        .query_entity(&ns, &EntityId::new(id))
844        .await
845        .map_err(internal)?;
846    Ok(Json(rec))
847}
848
849async fn invalidate_triple(
850    principal: AuthPrincipal,
851    Extension(store): Extension<Arc<dyn Store>>,
852    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
853    Path(id): Path<String>,
854    Query(q): Query<NsQuery>,
855) -> Result<StatusCode, ApiError> {
856    if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
857        return Err(ApiError::Forbidden);
858    }
859    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
860    kg.invalidate_triple(&ns, &id).await.map_err(internal)?;
861    Ok(StatusCode::NO_CONTENT)
862}
863
864#[derive(Deserialize, Default)]
865struct FindTriplesQuery {
866    namespace: Option<String>,
867    subject: Option<String>,
868    predicate: Option<String>,
869    object: Option<String>,
870}
871
872async fn find_triples(
873    principal: AuthPrincipal,
874    Extension(store): Extension<Arc<dyn Store>>,
875    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
876    Query(q): Query<FindTriplesQuery>,
877) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
878    if !principal.0.may(KNOWLEDGE_READ) {
879        return Err(ApiError::Forbidden);
880    }
881    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
882    let triples = kg
883        .find_triples(
884            &ns,
885            q.subject.as_deref().map(EntityId::new).as_ref(),
886            q.predicate.as_deref(),
887            q.object.as_deref().map(EntityId::new).as_ref(),
888        )
889        .await
890        .map_err(internal)?;
891    Ok(Json(triples))
892}
893
894async fn kg_timeline(
895    principal: AuthPrincipal,
896    Extension(store): Extension<Arc<dyn Store>>,
897    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
898    Query(q): Query<NsQuery>,
899) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
900    if !principal.0.may(KNOWLEDGE_READ) {
901        return Err(ApiError::Forbidden);
902    }
903    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
904    let triples = kg
905        .kg_timeline(&ns, q.limit.unwrap_or(50))
906        .await
907        .map_err(internal)?;
908    Ok(Json(triples))
909}
910
911async fn kg_stats(
912    principal: AuthPrincipal,
913    Extension(store): Extension<Arc<dyn Store>>,
914    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
915    Query(q): Query<NsQuery>,
916) -> Result<Json<ijima_core::KgStats>, ApiError> {
917    if !principal.0.may(KNOWLEDGE_READ) {
918        return Err(ApiError::Forbidden);
919    }
920    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
921    let stats = kg.knowledge_stats(&ns).await.map_err(internal)?;
922    Ok(Json(stats))
923}
924
925async fn ingest_turn(
926    principal: AuthPrincipal,
927    Extension(store): Extension<Arc<dyn Store>>,
928    Path(session_id): Path<String>,
929    Json(mut turn): Json<SessionTurn>,
930) -> Result<StatusCode, ApiError> {
931    if !principal.0.may(SESSION_INGEST) {
932        return Err(ApiError::Forbidden);
933    }
934    let ns = principal.0.personal_namespace();
935    turn.session_id = SessionId::new(session_id);
936    store.ingest_turn(&ns, turn).await.map_err(internal)?;
937    Ok(StatusCode::NO_CONTENT)
938}
939
940// TurnsQuery is unified into NsQuery above.
941
942#[derive(Serialize)]
943struct TurnsResponse {
944    turns: Vec<SessionTurn>,
945}
946
947async fn session_turns(
948    principal: AuthPrincipal,
949    Extension(store): Extension<Arc<dyn Store>>,
950    Path(session_id): Path<String>,
951    Query(q): Query<NsQuery>,
952) -> Result<Json<TurnsResponse>, ApiError> {
953    if !principal.0.may(MEMORY_READ) {
954        return Err(ApiError::Forbidden);
955    }
956    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
957    let turns = store
958        .session_turns(&ns, &SessionId::new(session_id), q.limit.unwrap_or(50))
959        .await
960        .map_err(internal)?;
961    Ok(Json(TurnsResponse { turns }))
962}
963
964/// Creates (or upserts) a session's metadata. `ended_at` is forced to
965/// `None` on create — use `POST /sessions/:id/end` to close a session.
966/// Auth: `session:ingest`. The session is stored in the caller's
967/// personal namespace (matching turn ingest).
968async fn create_session(
969    principal: AuthPrincipal,
970    Extension(store): Extension<Arc<dyn Store>>,
971    Json(mut session): Json<Session>,
972) -> Result<Json<IdResponse>, ApiError> {
973    if !principal.0.may(SESSION_INGEST) {
974        return Err(ApiError::Forbidden);
975    }
976    let ns = principal.0.personal_namespace();
977    if session.started_at.is_empty() {
978        session.started_at = std::time::SystemTime::now()
979            .duration_since(std::time::UNIX_EPOCH)
980            .map(|d| d.as_secs().to_string())
981            .unwrap_or_default();
982    }
983    session.ended_at = None;
984    let id = store.create_session(&ns, session).await.map_err(internal)?;
985    Ok(Json(IdResponse { id: id.0 }))
986}
987
988#[derive(Deserialize)]
989struct SessionListQuery {
990    namespace: Option<String>,
991    /// Optional harness filter (wire string, e.g. `pi`).
992    harness: Option<String>,
993    limit: Option<usize>,
994}
995
996/// Lists sessions in the effective namespace, newest first, optionally
997/// filtered by harness. Auth: `memory:read` (session metadata is
998/// read via the same capability as memory palace reads).
999async fn list_sessions(
1000    principal: AuthPrincipal,
1001    Extension(store): Extension<Arc<dyn Store>>,
1002    Query(q): Query<SessionListQuery>,
1003) -> Result<Json<Vec<Session>>, ApiError> {
1004    if !principal.0.may(MEMORY_READ) {
1005        return Err(ApiError::Forbidden);
1006    }
1007    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1008    let harness = q.harness.as_deref().map(Harness::from_wire_str);
1009    let limit = q.limit.unwrap_or(50).min(500);
1010    let sessions = store
1011        .list_sessions(&ns, harness.as_ref(), limit)
1012        .await
1013        .map_err(internal)?;
1014    Ok(Json(sessions))
1015}
1016
1017#[derive(Deserialize)]
1018struct EndSessionRequest {
1019    ended_at: String,
1020}
1021
1022/// Marks a session as ended. Scoped to the caller's personal namespace.
1023/// Auth: `session:ingest`.
1024async fn end_session(
1025    principal: AuthPrincipal,
1026    Extension(store): Extension<Arc<dyn Store>>,
1027    Path(session_id): Path<String>,
1028    Json(req): Json<EndSessionRequest>,
1029) -> Result<StatusCode, ApiError> {
1030    if !principal.0.may(SESSION_INGEST) {
1031        return Err(ApiError::Forbidden);
1032    }
1033    let ns = principal.0.personal_namespace();
1034    store
1035        .end_session(&ns, &SessionId::new(session_id), req.ended_at)
1036        .await
1037        .map_err(internal)?;
1038    Ok(StatusCode::NO_CONTENT)
1039}
1040
1041// ---------- mining review queue (ADR M2, M3) ----------
1042
1043/// Lists pending mining extractions in the effective namespace, newest
1044/// first. Auth: `mining:review`.
1045async fn list_pending(
1046    principal: AuthPrincipal,
1047    Extension(store): Extension<Arc<dyn Store>>,
1048    Query(q): Query<NsQuery>,
1049) -> Result<Json<Vec<QueuedExtraction>>, ApiError> {
1050    if !principal.0.may(MINING_REVIEW) {
1051        return Err(ApiError::Forbidden);
1052    }
1053    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1054    let limit = q.limit.unwrap_or(50).min(500);
1055    let pending = store.list_pending(&ns, limit).await.map_err(internal)?;
1056    Ok(Json(pending))
1057}
1058
1059/// Accepts a queued extraction: promotes it to the palace and removes it
1060/// from the queue. Auth: `mining:review`.
1061async fn accept_extraction(
1062    principal: AuthPrincipal,
1063    Extension(store): Extension<Arc<dyn Store>>,
1064    Path(id): Path<String>,
1065) -> Result<Json<AcceptedExtraction>, ApiError> {
1066    if !principal.0.may(MINING_REVIEW) {
1067        return Err(ApiError::Forbidden);
1068    }
1069    let ns = principal.0.personal_namespace();
1070    let accepted = store.accept_extraction(&ns, &id).await.map_err(internal)?;
1071    Ok(Json(accepted))
1072}
1073
1074/// Rejects a queued extraction: drops it without promoting. Auth:
1075/// `mining:review`. Returns 204.
1076async fn reject_extraction(
1077    principal: AuthPrincipal,
1078    Extension(store): Extension<Arc<dyn Store>>,
1079    Path(id): Path<String>,
1080) -> Result<StatusCode, ApiError> {
1081    if !principal.0.may(MINING_REVIEW) {
1082        return Err(ApiError::Forbidden);
1083    }
1084    let ns = principal.0.personal_namespace();
1085    store.reject_extraction(&ns, &id).await.map_err(internal)?;
1086    Ok(StatusCode::NO_CONTENT)
1087}
1088
1089// ---------- mining trigger (ADR M1, M3, M7) ----------
1090
1091/// Triggers an extraction pass over a session's turns: runs the rules tier
1092/// (always) plus the llm tier when `IJIMA_LLM_*` is configured, merges +
1093/// content-dedups, then ingests — `Auto` extractions archive to the palace,
1094/// `PendingReview` stage in the review queue. Auth: `mining:trigger`.
1095///
1096/// The llm agent's `HttpAgent::respond` blocks on its own tokio runtime, so
1097/// the synchronous `mine_all` pass runs on a blocking thread (via
1098/// [`tokio::task::spawn_blocking`]) to avoid a runtime-in-runtime panic
1099/// inside this async handler. The concrete [`HttpAgent`] is `Send`; the
1100/// `&mut dyn Agent` coercion happens *inside* the closure, so it never
1101/// crosses the spawn boundary as an unsized non-`Send` trait object.
1102#[cfg(feature = "mining")]
1103async fn trigger_mine(
1104    principal: AuthPrincipal,
1105    Extension(store): Extension<Arc<dyn Store>>,
1106    Path(session_id): Path<String>,
1107    Query(q): Query<NsQuery>,
1108) -> Result<Json<crate::mining_pipeline::MiningReport>, ApiError> {
1109    use proserpina_agent::http::HttpAgent;
1110
1111    if !principal.0.may(MINING_TRIGGER) {
1112        return Err(ApiError::Forbidden);
1113    }
1114    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1115
1116    // Fetch the session's turns (a generous limit — v0 mines the whole session).
1117    let turns = store
1118        .session_turns(&ns, &SessionId::new(session_id.clone()), 10_000)
1119        .await
1120        .map_err(internal)?;
1121    let turn_texts: Vec<String> = turns.into_iter().map(|t| t.content).collect();
1122    let ctx = crate::mining_pipeline::mining_context(&session_id, "general", Harness::Other);
1123
1124    // The extraction pass is synchronous (ADR M1); the llm agent bridges to
1125    // async HTTP internally via its own runtime + `block_on`. Run it on a
1126    // blocking thread so that `block_on` is legal (we are outside any async
1127    // executor here). `build_mining_agent` returns a concrete `Option<HttpAgent>`
1128    // — kept as the concrete type (not a trait object) so it stays `Send` for
1129    // the move into the spawned task.
1130    let extractions = tokio::task::spawn_blocking(move || {
1131        let mut agent: Option<HttpAgent> = build_mining_agent();
1132        let agent_dyn: Option<&mut dyn proserpina_agent::Agent> = agent
1133            .as_mut()
1134            .map(|a| a as &mut dyn proserpina_agent::Agent);
1135        ijima_miner::mine_all(&turn_texts, &ctx, agent_dyn)
1136    })
1137    .await
1138    .map_err(|e| {
1139        internal(ijima_core::IjimaError::Mining {
1140            detail: format!("extraction task failed: {e}"),
1141        })
1142    })?
1143    .map_err(internal)?;
1144
1145    let report = crate::mining_pipeline::ingest_extractions(store.as_ref(), &ns, extractions)
1146        .await
1147        .map_err(internal)?;
1148    Ok(Json(report))
1149}
1150
1151/// Constructs the llm extraction agent from `IJIMA_LLM_*` env config, or
1152/// `None` when mining should run rules-only (no `IJIMA_LLM_MODEL` /
1153/// `IJIMA_LLM_API_KEY` set). `mine_all(None)` then skips the llm tier.
1154///
1155/// Defaults `IJIMA_LLM_BASE_URL` to the DeepSeek endpoint. The agent uses a
1156/// single "Session Mining Extractor" persona covering both fact and pattern
1157/// extraction; v0 does not vary the agent persona per role (ADR M5,
1158/// single-shot). Returns a concrete [`HttpAgent`] (not a trait object) so it
1159/// remains `Send` for the blocking-thread move.
1160#[cfg(feature = "mining")]
1161fn build_mining_agent() -> Option<proserpina_agent::http::HttpAgent> {
1162    use proserpina_agent::{
1163        AgentId, Persona,
1164        http::{HttpAgent, HttpConfig},
1165    };
1166
1167    let base_url = std::env::var("IJIMA_LLM_BASE_URL")
1168        .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string());
1169    let model = std::env::var("IJIMA_LLM_MODEL").ok()?;
1170    let api_key = std::env::var("IJIMA_LLM_API_KEY").ok()?;
1171
1172    let persona = Persona::new("Session Mining Extractor")
1173        .with_framing(
1174            "You mine session transcripts for durable facts and recurring \
1175             patterns. Output one JSON object per line, each \
1176             {\"content\",\"project\",\"topic\",\"confidence\"}. Omit all \
1177             preamble. If nothing worth extracting, output nothing.",
1178        )
1179        .with_focus(
1180            "decisions, chosen tools, stated constraints, measurements, recurring workflows",
1181        );
1182
1183    Some(HttpAgent::new(
1184        AgentId::new("ijima-miner"),
1185        persona,
1186        HttpConfig {
1187            base_url,
1188            model,
1189            api_key,
1190        },
1191    ))
1192}
1193
1194// ===== Palace organization (memory:read) =====
1195
1196#[derive(Deserialize)]
1197struct NamespaceQuery {
1198    namespace: Option<String>,
1199}
1200
1201#[derive(Deserialize)]
1202struct RoomsQuery {
1203    namespace: Option<String>,
1204    project: Option<String>,
1205    limit: Option<usize>,
1206}
1207
1208#[derive(Deserialize)]
1209struct TunnelQuery {
1210    namespace: Option<String>,
1211    topic: String,
1212    project_a: String,
1213    project_b: String,
1214    limit: Option<usize>,
1215}
1216
1217#[derive(Deserialize)]
1218struct DiaryQuery {
1219    namespace: Option<String>,
1220    limit: Option<usize>,
1221}
1222
1223#[derive(Deserialize)]
1224struct MemoryBrowseQuery {
1225    namespace: Option<String>,
1226    project: Option<String>,
1227    topic: Option<String>,
1228    limit: Option<usize>,
1229}
1230
1231#[derive(Deserialize)]
1232struct ResolveRepoQuery {
1233    cwd: String,
1234}
1235
1236/// Lists rooms (topic cells), optionally filtered to a project. Auth: `memory:read`.
1237async fn list_rooms(
1238    principal: AuthPrincipal,
1239    Extension(store): Extension<Arc<dyn Store>>,
1240    Query(q): Query<RoomsQuery>,
1241) -> Result<Json<Vec<Room>>, ApiError> {
1242    if !principal.0.may(MEMORY_READ) {
1243        return Err(ApiError::Forbidden);
1244    }
1245    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1246    let limit = q.limit.unwrap_or(50).min(500);
1247    let rooms = store
1248        .list_rooms(&ns, q.project.as_deref(), limit)
1249        .await
1250        .map_err(internal)?;
1251    Ok(Json(rooms))
1252}
1253
1254/// Full project → topic → count taxonomy. Auth: `memory:read`.
1255async fn taxonomy(
1256    principal: AuthPrincipal,
1257    Extension(store): Extension<Arc<dyn Store>>,
1258    Query(q): Query<NamespaceQuery>,
1259) -> Result<Json<Vec<ProjectTaxon>>, ApiError> {
1260    if !principal.0.may(MEMORY_READ) {
1261        return Err(ApiError::Forbidden);
1262    }
1263    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1264    Ok(Json(store.taxonomy(&ns).await.map_err(internal)?))
1265}
1266
1267/// The palace graph: projects as nodes, shared-topic tunnels as edges. Auth: `memory:read`.
1268async fn palace_graph(
1269    principal: AuthPrincipal,
1270    Extension(store): Extension<Arc<dyn Store>>,
1271    Query(q): Query<NamespaceQuery>,
1272) -> Result<Json<PalaceGraph>, ApiError> {
1273    if !principal.0.may(MEMORY_READ) {
1274        return Err(ApiError::Forbidden);
1275    }
1276    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1277    Ok(Json(store.palace_graph(&ns).await.map_err(internal)?))
1278}
1279
1280/// Traverses a tunnel — the memories from both projects on a shared topic. Auth: `memory:read`.
1281async fn traverse_tunnel(
1282    principal: AuthPrincipal,
1283    Extension(store): Extension<Arc<dyn Store>>,
1284    Query(q): Query<TunnelQuery>,
1285) -> Result<Json<TunnelTraversal>, ApiError> {
1286    if !principal.0.may(MEMORY_READ) {
1287        return Err(ApiError::Forbidden);
1288    }
1289    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1290    let limit = q.limit.unwrap_or(50).min(500);
1291    Ok(Json(
1292        store
1293            .traverse_tunnel(&ns, &q.topic, &q.project_a, &q.project_b, limit)
1294            .await
1295            .map_err(internal)?,
1296    ))
1297}
1298
1299/// Appends a diary entry to the caller's namespace. Auth: `memory:write`.
1300async fn write_diary(
1301    principal: AuthPrincipal,
1302    Extension(store): Extension<Arc<dyn Store>>,
1303    Json(entry): Json<DiaryEntry>,
1304) -> Result<StatusCode, ApiError> {
1305    if !principal.0.may(MEMORY_WRITE) {
1306        return Err(ApiError::Forbidden);
1307    }
1308    let ns = principal.0.personal_namespace();
1309    store.write_diary(&ns, entry).await.map_err(internal)?;
1310    Ok(StatusCode::NO_CONTENT)
1311}
1312
1313/// Reads `agent`'s diary in the caller's namespace. Auth: `memory:read`.
1314async fn read_diary(
1315    principal: AuthPrincipal,
1316    Extension(store): Extension<Arc<dyn Store>>,
1317    Path(agent): Path<String>,
1318    Query(q): Query<DiaryQuery>,
1319) -> Result<Json<Vec<DiaryEntry>>, ApiError> {
1320    if !principal.0.may(MEMORY_READ) {
1321        return Err(ApiError::Forbidden);
1322    }
1323    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1324    let limit = q.limit.unwrap_or(50).min(500);
1325    Ok(Json(
1326        store
1327            .read_diary(&ns, &agent, limit)
1328            .await
1329            .map_err(internal)?,
1330    ))
1331}
1332
1333/// Browses memories (the `memory_recall` path), optionally filtered to
1334/// project/topic — distinct from the importance-ranked wake-up feed. Auth: `memory:read`.
1335async fn browse_memories(
1336    principal: AuthPrincipal,
1337    Extension(store): Extension<Arc<dyn Store>>,
1338    Query(q): Query<MemoryBrowseQuery>,
1339) -> Result<Json<Vec<Memory>>, ApiError> {
1340    if !principal.0.may(MEMORY_READ) {
1341        return Err(ApiError::Forbidden);
1342    }
1343    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1344    let limit = q.limit.unwrap_or(50).min(500);
1345    Ok(Json(
1346        store
1347            .list_memories_filtered(&ns, q.project.as_deref(), q.topic.as_deref(), limit)
1348            .await
1349            .map_err(internal)?,
1350    ))
1351}
1352
1353#[derive(Serialize)]
1354struct NamespaceStats {
1355    total: usize,
1356    projects: Vec<ProjectCount>,
1357}
1358
1359#[derive(Serialize)]
1360struct ProjectCount {
1361    project: String,
1362    count: usize,
1363}
1364
1365/// Read-accessible namespace stats (derived from room counts; unlike
1366/// `/status` which is admin-gated). Auth: `memory:read`.
1367async fn memory_stats(
1368    principal: AuthPrincipal,
1369    Extension(store): Extension<Arc<dyn Store>>,
1370    Query(q): Query<NamespaceQuery>,
1371) -> Result<Json<NamespaceStats>, ApiError> {
1372    if !principal.0.may(MEMORY_READ) {
1373        return Err(ApiError::Forbidden);
1374    }
1375    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1376    let rooms = store.list_rooms(&ns, None, 1000).await.map_err(internal)?;
1377    let total: usize = rooms.iter().map(|r| r.count).sum();
1378    let mut by_project: std::collections::BTreeMap<String, usize> =
1379        std::collections::BTreeMap::new();
1380    for r in &rooms {
1381        *by_project.entry(r.project.clone()).or_default() += r.count;
1382    }
1383    let projects = by_project
1384        .into_iter()
1385        .map(|(project, count)| ProjectCount { project, count })
1386        .collect();
1387    Ok(Json(NamespaceStats { total, projects }))
1388}
1389
1390// ===== Repo directory (global registry — Context Mapper) =====
1391
1392/// Registers/upserts a repo in the global registry (operator action). Auth: `admin`.
1393async fn register_repo(
1394    principal: AuthPrincipal,
1395    Extension(store): Extension<Arc<dyn Store>>,
1396    Json(repo): Json<RepoDirectory>,
1397) -> Result<StatusCode, ApiError> {
1398    if !principal.0.may(ADMIN) {
1399        return Err(ApiError::Forbidden);
1400    }
1401    store.register_repo(repo).await.map_err(internal)?;
1402    Ok(StatusCode::NO_CONTENT)
1403}
1404
1405/// Lists every registered repo (the ecosystem roster). Auth: `memory:read`.
1406async fn list_repos(
1407    principal: AuthPrincipal,
1408    Extension(store): Extension<Arc<dyn Store>>,
1409) -> Result<Json<Vec<RepoDirectory>>, ApiError> {
1410    if !principal.0.may(MEMORY_READ) {
1411        return Err(ApiError::Forbidden);
1412    }
1413    Ok(Json(store.list_repos().await.map_err(internal)?))
1414}
1415
1416/// Reverse-resolves a working directory to its registered repo. Auth: `memory:read`.
1417async fn resolve_repo(
1418    principal: AuthPrincipal,
1419    Extension(store): Extension<Arc<dyn Store>>,
1420    Query(q): Query<ResolveRepoQuery>,
1421) -> Result<Json<RepoDirectory>, ApiError> {
1422    if !principal.0.may(MEMORY_READ) {
1423        return Err(ApiError::Forbidden);
1424    }
1425    match store.resolve_repo(&q.cwd).await.map_err(internal)? {
1426        Some(repo) => Ok(Json(repo)),
1427        None => Err(ApiError::NotFound),
1428    }
1429}
1430
1431// ---------- federation control API (scaffold; feature `federation`) ----------
1432
1433/// `GET /federation/state` — the instance's federated self-description.
1434#[cfg(feature = "federation")]
1435async fn federation_state(
1436    Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1437) -> Json<FederationState> {
1438    Json(cfg.to_state())
1439}
1440
1441/// `POST /federation/routed-write` — apply a write under an authoritative scope.
1442///
1443/// Scaffold: applies the write locally with provenance stamping (origin =
1444/// this instance, authority = the scope) but performs **no** boundary
1445/// enforcement — no trust-tier egress filtering, scope/airgap deny, or
1446/// boundary transformation. Ijima's non-bypassable safety floor is the
1447/// follow-on (ADR `federation-control-api` §Deferred).
1448#[cfg(feature = "federation")]
1449async fn routed_write(
1450    principal: AuthPrincipal,
1451    Extension(store): Extension<Arc<dyn Store>>,
1452    Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1453    Json(write): Json<RoutedWrite>,
1454) -> Result<Json<RoutedWriteReceipt>, ApiError> {
1455    if !principal.0.may(MEMORY_WRITE) {
1456        return Err(ApiError::Forbidden);
1457    }
1458    let RoutedWrite {
1459        target: _,
1460        scope,
1461        operation: _,
1462        payload,
1463    } = write;
1464
1465    // === Boundary enforcement (non-bypassable; the federation ingress path) ===
1466    // (1) Airgap: a sovereign instance rejects all federation writes.
1467    if cfg.role == ijima_core::federation::InstanceRole::Airgapped {
1468        return Err(ApiError::Forbidden);
1469    }
1470    // (2) Scope filter: accept only writes for scopes this instance is
1471    //     authoritative for (default-deny for sovereignty).
1472    if !cfg.accepts_scope(&scope) {
1473        return Err(ApiError::BadRequest(format!(
1474            "out of authoritative scope: {}/{}",
1475            scope.namespace, scope.project
1476        )));
1477    }
1478
1479    let mut memory: Memory = serde_json::from_value(payload)
1480        .map_err(|e| ApiError::BadRequest(format!("payload is not a Memory: {e}")))?;
1481    // Stamp federation provenance: this instance applied it; the routed scope
1482    // is the source-of-truth authority for the record.
1483    memory.origin = ijima_core::provenance::InstanceId::local();
1484    memory.authority =
1485        ijima_core::provenance::AuthorityScope(format!("{}/{}", scope.namespace, scope.project));
1486    if memory.created_at.is_empty() {
1487        memory.created_at = std::time::SystemTime::now()
1488            .duration_since(std::time::UNIX_EPOCH)
1489            .map(|d| d.as_secs().to_string())
1490            .unwrap_or_default();
1491    }
1492    let ns = principal.0.personal_namespace();
1493
1494    // (3) Trust-tier ingress: doctrine arriving via federation is never
1495    //     auto-trusted — stage it as PendingReview (never auto-promoted).
1496    //     Lower tiers (Explicit/Mined/AutoCapture) cross as-is.
1497    let (commit, mut warnings) = if memory.source == MemorySource::Doctrine {
1498        let pending = store
1499            .enqueue_extraction(&ns, memory, 0.5)
1500            .await
1501            .map_err(internal)?;
1502        (
1503            pending,
1504            vec!["doctrine downgraded to PendingReview (trust-tier ingress rule)".into()],
1505        )
1506    } else {
1507        let id = store.store_memory(&ns, memory).await.map_err(internal)?;
1508        (id.0, Vec::new())
1509    };
1510    warnings.push("boundary enforcement: scope + airgap + doctrine-downgrade applied".into());
1511    Ok(Json(RoutedWriteReceipt {
1512        accepted: true,
1513        instance: cfg.instance_id.clone(),
1514        scope,
1515        commit: Some(commit),
1516        warnings,
1517    }))
1518}
1519
1520/// `POST /federation/conflict-signal` — poll for a conflict on a scope.
1521///
1522/// Scaffold: no conflict detection yet. Returns `404` (no active conflict);
1523/// the single-instance deployment has no peer to conflict with.
1524#[cfg(feature = "federation")]
1525async fn conflict_signal(
1526    Json(_scope): Json<AuthoritativeScope>,
1527) -> Result<Json<ConflictSignal>, ApiError> {
1528    Err(ApiError::NotFound)
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533    use super::*;
1534    use crate::IjimaAuth;
1535    use axum::body::Body;
1536    use axum::http::{Request, StatusCode};
1537    use ijima_core::{harness::Harness, memory::MemorySource};
1538    use tower::ServiceExt;
1539
1540    async fn app_with_store() -> (Router, Arc<IjimaAuth>) {
1541        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1542        let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1543        let store: Arc<dyn Store> = store_inner.clone();
1544        let kg: Arc<dyn KnowledgeGraph> = store_inner;
1545        (
1546            app(
1547                auth.clone(),
1548                store,
1549                kg,
1550                None,
1551                Arc::new(crate::redaction::Redactor::new()),
1552                #[cfg(feature = "rate-limit")]
1553                None,
1554                #[cfg(feature = "federation")]
1555                Arc::new(InstanceFederationConfig::default()),
1556            ),
1557            auth,
1558        )
1559    }
1560
1561    /// Like [`app_with_store`] but with a custom federation config — for
1562    /// boundary-enforcement tests (airgap, out-of-scope).
1563    #[cfg(feature = "federation")]
1564    async fn app_with_federation_config(
1565        config: InstanceFederationConfig,
1566    ) -> (Router, Arc<IjimaAuth>) {
1567        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1568        let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1569        let store: Arc<dyn Store> = store_inner.clone();
1570        let kg: Arc<dyn KnowledgeGraph> = store_inner;
1571        (
1572            app(
1573                auth.clone(),
1574                store,
1575                kg,
1576                None,
1577                Arc::new(crate::redaction::Redactor::new()),
1578                #[cfg(feature = "rate-limit")]
1579                None,
1580                Arc::new(config),
1581            ),
1582            auth,
1583        )
1584    }
1585
1586    fn bearer(auth: &IjimaAuth, principal: &str, cap: &str) -> String {
1587        format!(
1588            "Bearer {}",
1589            auth.issue_bearer(principal, cap).expect("issue")
1590        )
1591    }
1592
1593    #[cfg(feature = "federation")]
1594    #[tokio::test]
1595    async fn federation_state_returns_local_config() {
1596        let (app, _auth) = app_with_store().await;
1597        let res = app
1598            .oneshot(
1599                Request::builder()
1600                    .uri("/federation/state")
1601                    .body(Body::empty())
1602                    .unwrap(),
1603            )
1604            .await
1605            .unwrap();
1606        assert_eq!(res.status(), StatusCode::OK);
1607        let state = body_json(res).await;
1608        assert_eq!(state["instance_id"], "local");
1609        assert_eq!(state["role"], "Unifying");
1610    }
1611
1612    #[cfg(feature = "federation")]
1613    #[tokio::test]
1614    async fn routed_write_applies_a_memory() {
1615        let (app, auth) = app_with_store().await;
1616        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1617        let body = serde_json::json!({
1618            "target": "local",
1619            "scope": {"namespace": "local", "project": "Dominic"},
1620            "operation": "Create",
1621            "payload": {
1622                "id": "mem_fed_test",
1623                "content": "federated hello",
1624                "project": "Dominic",
1625                "topic": "federated",
1626                "source": "Explicit",
1627                "harness": "Dominic"
1628            }
1629        })
1630        .to_string();
1631        let res = app
1632            .oneshot(
1633                Request::builder()
1634                    .method("POST")
1635                    .uri("/federation/routed-write")
1636                    .header("authorization", &write)
1637                    .header("content-type", "application/json")
1638                    .body(Body::from(body))
1639                    .unwrap(),
1640            )
1641            .await
1642            .unwrap();
1643        assert_eq!(res.status(), StatusCode::OK);
1644        let receipt = body_json(res).await;
1645        assert_eq!(receipt["accepted"], true);
1646        assert!(receipt["commit"].as_str().is_some());
1647        assert_eq!(
1648            receipt["warnings"][0],
1649            "boundary enforcement: scope + airgap + doctrine-downgrade applied"
1650        );
1651    }
1652
1653    #[cfg(feature = "federation")]
1654    #[tokio::test]
1655    async fn routed_write_requires_memory_write() {
1656        let (app, auth) = app_with_store().await;
1657        let read = bearer(&auth, "elliott", MEMORY_READ); // read cap, not write
1658        let body = serde_json::json!({
1659            "target": "local",
1660            "scope": {"namespace": "local", "project": "Dominic"},
1661            "operation": "Create",
1662            "payload": {
1663                "id": "x", "content": "c", "project": "p",
1664                "topic": "t", "source": "Explicit", "harness": "Dominic"
1665            }
1666        })
1667        .to_string();
1668        let res = app
1669            .oneshot(
1670                Request::builder()
1671                    .method("POST")
1672                    .uri("/federation/routed-write")
1673                    .header("authorization", &read)
1674                    .header("content-type", "application/json")
1675                    .body(Body::from(body))
1676                    .unwrap(),
1677            )
1678            .await
1679            .unwrap();
1680        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1681    }
1682
1683    #[cfg(feature = "federation")]
1684    #[tokio::test]
1685    async fn routed_write_rejects_out_of_scope() {
1686        let (app, auth) = app_with_store().await;
1687        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1688        // default config is authoritative for {local, *}; {shared, ...} is out of scope
1689        let body = serde_json::json!({
1690            "target": "local",
1691            "scope": {"namespace": "shared", "project": "Dominic"},
1692            "operation": "Create",
1693            "payload": {
1694                "id": "x", "content": "c", "project": "p",
1695                "topic": "t", "source": "Explicit", "harness": "Dominic"
1696            }
1697        })
1698        .to_string();
1699        let res = app
1700            .oneshot(
1701                Request::builder()
1702                    .method("POST")
1703                    .uri("/federation/routed-write")
1704                    .header("authorization", &write)
1705                    .header("content-type", "application/json")
1706                    .body(Body::from(body))
1707                    .unwrap(),
1708            )
1709            .await
1710            .unwrap();
1711        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
1712    }
1713
1714    #[cfg(feature = "federation")]
1715    #[tokio::test]
1716    async fn routed_write_rejects_when_airgapped() {
1717        let cfg = InstanceFederationConfig {
1718            role: ijima_core::federation::InstanceRole::Airgapped,
1719            ..InstanceFederationConfig::default()
1720        };
1721        let (app, auth) = app_with_federation_config(cfg).await;
1722        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1723        let body = serde_json::json!({
1724            "target": "local",
1725            "scope": {"namespace": "local", "project": "Dominic"},
1726            "operation": "Create",
1727            "payload": {
1728                "id": "x", "content": "c", "project": "p",
1729                "topic": "t", "source": "Explicit", "harness": "Dominic"
1730            }
1731        })
1732        .to_string();
1733        let res = app
1734            .oneshot(
1735                Request::builder()
1736                    .method("POST")
1737                    .uri("/federation/routed-write")
1738                    .header("authorization", &write)
1739                    .header("content-type", "application/json")
1740                    .body(Body::from(body))
1741                    .unwrap(),
1742            )
1743            .await
1744            .unwrap();
1745        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1746    }
1747
1748    #[cfg(feature = "federation")]
1749    #[tokio::test]
1750    async fn routed_write_downgrades_doctrine_to_pending() {
1751        let (app, auth) = app_with_store().await;
1752        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1753        let body = serde_json::json!({
1754            "target": "local",
1755            "scope": {"namespace": "local", "project": "Dominic"},
1756            "operation": "Create",
1757            "payload": {
1758                "id": "mem_doctrine",
1759                "content": "peer-claimed doctrine",
1760                "project": "Dominic",
1761                "topic": "federated",
1762                "source": "Doctrine",
1763                "harness": "Dominic"
1764            }
1765        })
1766        .to_string();
1767        let res = app
1768            .oneshot(
1769                Request::builder()
1770                    .method("POST")
1771                    .uri("/federation/routed-write")
1772                    .header("authorization", &write)
1773                    .header("content-type", "application/json")
1774                    .body(Body::from(body))
1775                    .unwrap(),
1776            )
1777            .await
1778            .unwrap();
1779        assert_eq!(res.status(), StatusCode::OK);
1780        let receipt = body_json(res).await;
1781        assert_eq!(receipt["accepted"], true);
1782        assert_eq!(
1783            receipt["warnings"][0],
1784            "doctrine downgraded to PendingReview (trust-tier ingress rule)"
1785        );
1786    }
1787
1788    #[cfg(feature = "federation")]
1789    #[tokio::test]
1790    async fn conflict_signal_returns_404_when_none() {
1791        let (app, _auth) = app_with_store().await;
1792        let body = serde_json::json!({"namespace": "shared", "project": "Dominic"}).to_string();
1793        let res = app
1794            .oneshot(
1795                Request::builder()
1796                    .method("POST")
1797                    .uri("/federation/conflict-signal")
1798                    .header("content-type", "application/json")
1799                    .body(Body::from(body))
1800                    .unwrap(),
1801            )
1802            .await
1803            .unwrap();
1804        assert_eq!(res.status(), StatusCode::NOT_FOUND);
1805    }
1806
1807    fn sample_memory_json(id: &str) -> String {
1808        serde_json::json!({
1809            "id": id,
1810            "content": "decided to wire the daemon",
1811            "project": "ijima",
1812            "topic": "api",
1813            "source": "Explicit",
1814            "harness": "Pi",
1815            "session_id": "sess_1",
1816            "importance": 0.5,
1817            "created_at": "0",
1818        })
1819        .to_string()
1820    }
1821
1822    #[tokio::test]
1823    async fn health_is_public() {
1824        let (app, _) = app_with_store().await;
1825        let res = app
1826            .oneshot(
1827                Request::builder()
1828                    .uri("/health")
1829                    .body(Body::empty())
1830                    .unwrap(),
1831            )
1832            .await
1833            .unwrap();
1834        assert_eq!(res.status(), StatusCode::OK);
1835    }
1836
1837    #[tokio::test]
1838    async fn recall_without_auth_is_401() {
1839        let (app, _) = app_with_store().await;
1840        let res = app
1841            .oneshot(
1842                Request::builder()
1843                    .uri("/memories/mem_1")
1844                    .body(Body::empty())
1845                    .unwrap(),
1846            )
1847            .await
1848            .unwrap();
1849        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
1850    }
1851
1852    #[tokio::test]
1853    async fn store_then_recall_round_trips() {
1854        let (app, auth) = app_with_store().await;
1855        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1856        let read = bearer(&auth, "elliott", MEMORY_READ);
1857
1858        // POST /memories
1859        let res = app
1860            .clone()
1861            .oneshot(
1862                Request::builder()
1863                    .method("POST")
1864                    .uri("/memories")
1865                    .header("authorization", &write)
1866                    .header("content-type", "application/json")
1867                    .body(Body::from(sample_memory_json("mem_1")))
1868                    .unwrap(),
1869            )
1870            .await
1871            .unwrap();
1872        assert_eq!(res.status(), StatusCode::OK);
1873
1874        // GET /memories/mem_1
1875        let res = app
1876            .oneshot(
1877                Request::builder()
1878                    .uri("/memories/mem_1")
1879                    .header("authorization", &read)
1880                    .body(Body::empty())
1881                    .unwrap(),
1882            )
1883            .await
1884            .unwrap();
1885        assert_eq!(res.status(), StatusCode::OK);
1886        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1887            .await
1888            .unwrap();
1889        let mem: Memory = serde_json::from_slice(&body).unwrap();
1890        assert_eq!(mem.content, "decided to wire the daemon");
1891        assert_eq!(mem.harness, Harness::Pi);
1892        assert_eq!(mem.source, MemorySource::Explicit);
1893    }
1894
1895    #[tokio::test]
1896    async fn store_with_read_only_token_is_403() {
1897        let (app, auth) = app_with_store().await;
1898        let read = bearer(&auth, "elliott", MEMORY_READ);
1899        let res = app
1900            .oneshot(
1901                Request::builder()
1902                    .method("POST")
1903                    .uri("/memories")
1904                    .header("authorization", &read)
1905                    .header("content-type", "application/json")
1906                    .body(Body::from(sample_memory_json("mem_x")))
1907                    .unwrap(),
1908            )
1909            .await
1910            .unwrap();
1911        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1912    }
1913
1914    #[tokio::test]
1915    async fn namespace_isolation_across_principals() {
1916        let (app, auth) = app_with_store().await;
1917        // alice stores
1918        let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1919        let _ = app
1920            .clone()
1921            .oneshot(
1922                Request::builder()
1923                    .method("POST")
1924                    .uri("/memories")
1925                    .header("authorization", &alice_write)
1926                    .header("content-type", "application/json")
1927                    .body(Body::from(sample_memory_json("mem_a")))
1928                    .unwrap(),
1929            )
1930            .await
1931            .unwrap();
1932        // bob cannot recall alice's memory
1933        let bob_read = bearer(&auth, "bob", MEMORY_READ);
1934        let res = app
1935            .oneshot(
1936                Request::builder()
1937                    .uri("/memories/mem_a")
1938                    .header("authorization", &bob_read)
1939                    .body(Body::empty())
1940                    .unwrap(),
1941            )
1942            .await
1943            .unwrap();
1944        assert_eq!(res.status(), StatusCode::NOT_FOUND);
1945    }
1946
1947    #[tokio::test]
1948    async fn promote_redacts_secrets_and_leaves_original_intact() {
1949        let (app, auth) = app_with_store().await;
1950        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1951        let read = bearer(&auth, "elliott", MEMORY_READ);
1952        let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1953
1954        // WS3: elliott must be a member of the promotion target's org
1955        // wall — grant via the admin route (full-stack setup).
1956        let admin = bearer(&auth, "root", ADMIN);
1957        let res = app
1958            .clone()
1959            .oneshot(
1960                Request::builder()
1961                    .method("POST")
1962                    .uri("/namespaces/grant")
1963                    .header("authorization", &admin)
1964                    .header("content-type", "application/json")
1965                    .body(Body::from(
1966                        serde_json::json!({
1967                            "namespace": "ns_team_shared",
1968                            "principal": "elliott"
1969                        })
1970                        .to_string(),
1971                    ))
1972                    .unwrap(),
1973            )
1974            .await
1975            .unwrap();
1976        assert_eq!(res.status(), StatusCode::OK, "membership grant");
1977
1978        // Store a personal memory containing a secret.
1979        let body = serde_json::json!({
1980            "id": "mem_secret",
1981            "content": "deploy key sk-abcdefghijklmnopqrstuvwxyz1234567890 contact ops@test.com",
1982            "project": "ijima",
1983            "topic": "ops",
1984            "source": "Explicit",
1985            "harness": "Pi",
1986        })
1987        .to_string();
1988        let res = app
1989            .clone()
1990            .oneshot(
1991                Request::builder()
1992                    .method("POST")
1993                    .uri("/memories")
1994                    .header("authorization", &write)
1995                    .header("content-type", "application/json")
1996                    .body(Body::from(body))
1997                    .unwrap(),
1998            )
1999            .await
2000            .unwrap();
2001        assert_eq!(res.status(), StatusCode::OK);
2002
2003        // Promote to a shared namespace.
2004        let promote_body = serde_json::json!({
2005            "target_namespace": "ns_team_shared",
2006        })
2007        .to_string();
2008        let res = app
2009            .clone()
2010            .oneshot(
2011                Request::builder()
2012                    .method("POST")
2013                    .uri("/memories/mem_secret/promote")
2014                    .header("authorization", &promote)
2015                    .header("content-type", "application/json")
2016                    .body(Body::from(promote_body))
2017                    .unwrap(),
2018            )
2019            .await
2020            .unwrap();
2021        assert_eq!(res.status(), StatusCode::OK);
2022        let resp: serde_json::Value = serde_json::from_slice(
2023            &axum::body::to_bytes(res.into_body(), usize::MAX)
2024                .await
2025                .unwrap(),
2026        )
2027        .unwrap();
2028        let new_id = resp["id"].as_str().unwrap();
2029        assert_eq!(new_id, "mem_secret__shared");
2030        let cats: Vec<&str> = resp["redactions"]
2031            .as_array()
2032            .unwrap()
2033            .iter()
2034            .map(|r| r["category"].as_str().unwrap())
2035            .collect();
2036        assert!(cats.contains(&"api_key"));
2037        assert!(cats.contains(&"email"));
2038
2039        // The original personal memory is untouched (verbatim).
2040        let res = app
2041            .clone()
2042            .oneshot(
2043                Request::builder()
2044                    .uri("/memories/mem_secret")
2045                    .header("authorization", &read)
2046                    .body(Body::empty())
2047                    .unwrap(),
2048            )
2049            .await
2050            .unwrap();
2051        let orig: Memory = serde_json::from_slice(
2052            &axum::body::to_bytes(res.into_body(), usize::MAX)
2053                .await
2054                .unwrap(),
2055        )
2056        .unwrap();
2057        assert!(orig.content.contains("sk-abcdef"));
2058        assert!(orig.content.contains("ops@test.com"));
2059
2060        // The promoted shared copy is readable via ?namespace= and has
2061        // secrets scrubbed.
2062        let res = app
2063            .oneshot(
2064                Request::builder()
2065                    .uri("/memories/mem_secret__shared?namespace=ns_team_shared")
2066                    .header("authorization", &read)
2067                    .body(Body::empty())
2068                    .unwrap(),
2069            )
2070            .await
2071            .unwrap();
2072        assert_eq!(res.status(), StatusCode::OK);
2073        let shared: Memory = serde_json::from_slice(
2074            &axum::body::to_bytes(res.into_body(), usize::MAX)
2075                .await
2076                .unwrap(),
2077        )
2078        .unwrap();
2079        assert!(shared.content.contains("[REDACTED:api_key]"));
2080        assert!(shared.content.contains("[REDACTED:email]"));
2081        assert!(!shared.content.contains("sk-abcdef"));
2082        assert!(!shared.content.contains("ops@test.com"));
2083        // Provenance back-reference.
2084        assert_eq!(shared.session_id.as_deref(), Some("mem_secret"));
2085    }
2086
2087    #[tokio::test]
2088    async fn promote_requires_trust_promote_not_memory_write() {
2089        // ADR provenance-tier: raising trust is costlier than writing at a
2090        // tier, so promote_memory requires trust:promote (codim 4), not
2091        // memory:write (codim 2). A memory:write-only token gets 403.
2092        let (app, auth) = app_with_store().await;
2093        let write = bearer(&auth, "elliott", MEMORY_WRITE);
2094        let body = serde_json::json!({
2095            "id": "mem_p",
2096            "content": "provenance tier test",
2097            "project": "ijima",
2098            "topic": "t",
2099            "source": "Explicit",
2100            "harness": "Pi",
2101        })
2102        .to_string();
2103        // Store succeeds with memory:write.
2104        let res = app
2105            .clone()
2106            .oneshot(
2107                Request::builder()
2108                    .method("POST")
2109                    .uri("/memories")
2110                    .header("authorization", &write)
2111                    .header("content-type", "application/json")
2112                    .body(Body::from(body))
2113                    .unwrap(),
2114            )
2115            .await
2116            .unwrap();
2117        assert_eq!(res.status(), StatusCode::OK);
2118
2119        // Promote is forbidden with only memory:write.
2120        let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2121        let res = app
2122            .clone()
2123            .oneshot(
2124                Request::builder()
2125                    .method("POST")
2126                    .uri("/memories/mem_p/promote")
2127                    .header("authorization", &write)
2128                    .header("content-type", "application/json")
2129                    .body(Body::from(promote_body))
2130                    .unwrap(),
2131            )
2132            .await
2133            .unwrap();
2134        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2135
2136        // WS3: the promotion target is membership-gated — grant elliott
2137        // into ns_team_shared via the admin route, then the trust:promote
2138        // holder succeeds.
2139        let admin = bearer(&auth, "root", ADMIN);
2140        let res = app
2141            .clone()
2142            .oneshot(
2143                Request::builder()
2144                    .method("POST")
2145                    .uri("/namespaces/grant")
2146                    .header("authorization", &admin)
2147                    .header("content-type", "application/json")
2148                    .body(Body::from(
2149                        serde_json::json!({
2150                            "namespace": "ns_team_shared",
2151                            "principal": "elliott"
2152                        })
2153                        .to_string(),
2154                    ))
2155                    .unwrap(),
2156            )
2157            .await
2158            .unwrap();
2159        assert_eq!(res.status(), StatusCode::OK, "membership grant");
2160
2161        let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
2162        let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2163        let res = app
2164            .oneshot(
2165                Request::builder()
2166                    .method("POST")
2167                    .uri("/memories/mem_p/promote")
2168                    .header("authorization", &promote)
2169                    .header("content-type", "application/json")
2170                    .body(Body::from(promote_body))
2171                    .unwrap(),
2172            )
2173            .await
2174            .unwrap();
2175        assert_eq!(res.status(), StatusCode::OK);
2176    }
2177
2178    // ---------- WS3 org walls ----------
2179
2180    /// The full wall lifecycle: non-member 403 → admin grants → member
2181    /// 200 → revoke → 403 again. Also pins the admin bypass.
2182    #[tokio::test]
2183    async fn shared_namespace_membership_lifecycle() {
2184        let (app, auth) = app_with_store().await;
2185        let rw = bearer(&auth, "elliott", MEMORY_WRITE);
2186        let admin = bearer(&auth, "root", ADMIN);
2187
2188        let write_into = |app: Router, token: String, n: u8| async move {
2189            app.oneshot(
2190                Request::builder()
2191                    .method("POST")
2192                    .uri("/memories?namespace=ns_ia_shared")
2193                    .header("authorization", token)
2194                    .header("content-type", "application/json")
2195                    .body(Body::from(
2196                        serde_json::json!({
2197                            "id": format!("mem_wall_{n}"),
2198                            "content": format!("org-wall probe {n}"),
2199                            "project": "ijima",
2200                            "topic": "ws3",
2201                            "source": "Explicit",
2202                            "harness": "Pi",
2203                            "importance": 0.5,
2204                            "created_at": "0",
2205                        })
2206                        .to_string(),
2207                    ))
2208                    .unwrap(),
2209            )
2210            .await
2211            .unwrap()
2212        };
2213
2214        // 1. Non-member is walled out.
2215        let res = write_into(app.clone(), rw.clone(), 1).await;
2216        assert_eq!(
2217            res.status(),
2218            StatusCode::FORBIDDEN,
2219            "non-member must be walled"
2220        );
2221
2222        // 2. Admin bypasses without membership.
2223        let res = write_into(app.clone(), admin.clone(), 2).await;
2224        assert_eq!(res.status(), StatusCode::OK, "admin bypass");
2225
2226        // 3. Non-admin cannot grant.
2227        let res = app
2228            .clone()
2229            .oneshot(
2230                Request::builder()
2231                    .method("POST")
2232                    .uri("/namespaces/grant")
2233                    .header("authorization", rw.clone())
2234                    .header("content-type", "application/json")
2235                    .body(Body::from(
2236                        serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2237                            .to_string(),
2238                    ))
2239                    .unwrap(),
2240            )
2241            .await
2242            .unwrap();
2243        assert_eq!(res.status(), StatusCode::FORBIDDEN, "grant requires admin");
2244
2245        // 4. Admin grants → member writes fine.
2246        let res = app
2247            .clone()
2248            .oneshot(
2249                Request::builder()
2250                    .method("POST")
2251                    .uri("/namespaces/grant")
2252                    .header("authorization", admin.clone())
2253                    .header("content-type", "application/json")
2254                    .body(Body::from(
2255                        serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2256                            .to_string(),
2257                    ))
2258                    .unwrap(),
2259            )
2260            .await
2261            .unwrap();
2262        assert_eq!(res.status(), StatusCode::OK);
2263        let res = write_into(app.clone(), rw.clone(), 3).await;
2264        assert_eq!(res.status(), StatusCode::OK, "member passes");
2265
2266        // 5. Members listing (admin) shows the grant.
2267        let res = app
2268            .clone()
2269            .oneshot(
2270                Request::builder()
2271                    .uri("/namespaces/members?namespace=ns_ia_shared")
2272                    .header("authorization", admin.clone())
2273                    .body(Body::empty())
2274                    .unwrap(),
2275            )
2276            .await
2277            .unwrap();
2278        assert_eq!(res.status(), StatusCode::OK);
2279        let members = body_json(res).await;
2280        assert_eq!(members[0]["principal"].as_str(), Some("elliott"));
2281        assert_eq!(members[0]["granted_by"].as_str(), Some("root"));
2282
2283        // 6. Revoke → walled again.
2284        let res = app
2285            .clone()
2286            .oneshot(
2287                Request::builder()
2288                    .method("POST")
2289                    .uri("/namespaces/revoke")
2290                    .header("authorization", admin.clone())
2291                    .header("content-type", "application/json")
2292                    .body(Body::from(
2293                        serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2294                            .to_string(),
2295                    ))
2296                    .unwrap(),
2297            )
2298            .await
2299            .unwrap();
2300        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2301        let res = write_into(app, rw, 4).await;
2302        assert_eq!(
2303            res.status(),
2304            StatusCode::FORBIDDEN,
2305            "revoked member is walled"
2306        );
2307    }
2308
2309    /// Open namespaces stay open: doctrine and import staging need no
2310    /// membership.
2311    #[tokio::test]
2312    async fn doctrine_and_import_namespaces_stay_open() {
2313        let (app, auth) = app_with_store().await;
2314        let read = bearer(&auth, "elliott", MEMORY_READ);
2315
2316        let res = app
2317            .clone()
2318            .oneshot(
2319                Request::builder()
2320                    .uri("/memories?namespace=ns_doctrine&limit=5")
2321                    .header("authorization", read.clone())
2322                    .body(Body::empty())
2323                    .unwrap(),
2324            )
2325            .await
2326            .unwrap();
2327        assert_eq!(res.status(), StatusCode::OK, "doctrine is readable by all");
2328
2329        let res = app
2330            .clone()
2331            .oneshot(
2332                Request::builder()
2333                    .uri("/memories?namespace=ns_import_probe&limit=5")
2334                    .header("authorization", read)
2335                    .body(Body::empty())
2336                    .unwrap(),
2337            )
2338            .await
2339            .unwrap();
2340        assert_eq!(res.status(), StatusCode::OK, "import staging is open");
2341    }
2342
2343    #[tokio::test]
2344    async fn cross_principal_personal_namespace_is_forbidden() {
2345        let (app, auth) = app_with_store().await;
2346        // Alice stores a memory.
2347        let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
2348        let _ = app
2349            .clone()
2350            .oneshot(
2351                Request::builder()
2352                    .method("POST")
2353                    .uri("/memories")
2354                    .header("authorization", &alice_write)
2355                    .header("content-type", "application/json")
2356                    .body(Body::from(
2357                        serde_json::json!({
2358                            "id": "mem_a",
2359                            "content": "alice only",
2360                            "project": "x",
2361                            "topic": "x",
2362                            "source": "Explicit",
2363                            "harness": "Pi",
2364                        })
2365                        .to_string(),
2366                    ))
2367                    .unwrap(),
2368            )
2369            .await
2370            .unwrap();
2371
2372        // Bob tries to read alice's personal namespace explicitly.
2373        let bob_read = bearer(&auth, "bob", MEMORY_READ);
2374        let res = app
2375            .oneshot(
2376                Request::builder()
2377                    .uri("/memories/mem_a?namespace=ns_alice_private")
2378                    .header("authorization", &bob_read)
2379                    .body(Body::empty())
2380                    .unwrap(),
2381            )
2382            .await
2383            .unwrap();
2384        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2385    }
2386
2387    #[tokio::test]
2388    async fn doctrine_ingest_requires_admin_and_is_readable_shared() {
2389        let (app, auth) = app_with_store().await;
2390        let admin = bearer(&auth, "ci", "admin");
2391        let read = bearer(&auth, "anyone", MEMORY_READ);
2392
2393        // Non-admin cannot ingest doctrine.
2394        let res = app
2395            .clone()
2396            .oneshot(
2397                Request::builder()
2398                    .method("POST")
2399                    .uri("/doctrine")
2400                    .header("authorization", &read)
2401                    .header("content-type", "application/json")
2402                    .body(Body::from(
2403                        serde_json::json!({
2404                            "id": "d1",
2405                            "content": "doctrine body",
2406                            "project": "ijima",
2407                            "topic": "arch",
2408                        })
2409                        .to_string(),
2410                    ))
2411                    .unwrap(),
2412            )
2413            .await
2414            .unwrap();
2415        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2416
2417        // Admin ingests.
2418        let res = app
2419            .clone()
2420            .oneshot(
2421                Request::builder()
2422                    .method("POST")
2423                    .uri("/doctrine")
2424                    .header("authorization", &admin)
2425                    .header("content-type", "application/json")
2426                    .body(Body::from(
2427                        serde_json::json!({
2428                            "id": "d1",
2429                            "content": "doctrine body",
2430                            "project": "ijima",
2431                            "topic": "arch",
2432                        })
2433                        .to_string(),
2434                    ))
2435                    .unwrap(),
2436            )
2437            .await
2438            .unwrap();
2439        assert_eq!(res.status(), StatusCode::OK);
2440
2441        // Any read-capable principal can recall doctrine from ns_doctrine.
2442        let res = app
2443            .oneshot(
2444                Request::builder()
2445                    .uri("/memories/d1?namespace=ns_doctrine")
2446                    .header("authorization", &read)
2447                    .body(Body::empty())
2448                    .unwrap(),
2449            )
2450            .await
2451            .unwrap();
2452        assert_eq!(res.status(), StatusCode::OK);
2453        let mem: Memory = serde_json::from_slice(
2454            &axum::body::to_bytes(res.into_body(), usize::MAX)
2455                .await
2456                .unwrap(),
2457        )
2458        .unwrap();
2459        assert_eq!(mem.content, "doctrine body");
2460        assert_eq!(mem.source, ijima_core::memory::MemorySource::Doctrine);
2461    }
2462
2463    #[tokio::test]
2464    async fn wakeup_composes_personal_and_doctrine() {
2465        let (app, auth) = app_with_store().await;
2466        let write = bearer(&auth, "elliott", MEMORY_WRITE);
2467        let admin = bearer(&auth, "ci", "admin");
2468        let read = bearer(&auth, "elliott", MEMORY_READ);
2469
2470        // Store a personal memory.
2471        let _ = app
2472            .clone()
2473            .oneshot(
2474                Request::builder()
2475                    .method("POST")
2476                    .uri("/memories")
2477                    .header("authorization", &write)
2478                    .header("content-type", "application/json")
2479                    .body(Body::from(
2480                        serde_json::json!({
2481                            "id": "mem_p",
2482                            "content": "personal essential",
2483                            "project": "ijima",
2484                            "topic": "x",
2485                            "source": "Explicit",
2486                            "harness": "Pi",
2487                        })
2488                        .to_string(),
2489                    ))
2490                    .unwrap(),
2491            )
2492            .await
2493            .unwrap();
2494
2495        // Ingest doctrine.
2496        let _ = app
2497            .clone()
2498            .oneshot(
2499                Request::builder()
2500                    .method("POST")
2501                    .uri("/doctrine")
2502                    .header("authorization", &admin)
2503                    .header("content-type", "application/json")
2504                    .body(Body::from(
2505                        serde_json::json!({
2506                            "id": "doc_1",
2507                            "content": "doctrine baseline",
2508                            "project": "ijima",
2509                            "topic": "arch",
2510                        })
2511                        .to_string(),
2512                    ))
2513                    .unwrap(),
2514            )
2515            .await
2516            .unwrap();
2517
2518        // Wake-up composes both.
2519        let res = app
2520            .oneshot(
2521                Request::builder()
2522                    .uri("/wakeup")
2523                    .header("authorization", &read)
2524                    .body(Body::empty())
2525                    .unwrap(),
2526            )
2527            .await
2528            .unwrap();
2529        assert_eq!(res.status(), StatusCode::OK);
2530        let body: serde_json::Value = serde_json::from_slice(
2531            &axum::body::to_bytes(res.into_body(), usize::MAX)
2532                .await
2533                .unwrap(),
2534        )
2535        .unwrap();
2536        assert_eq!(body["identity"]["principal"], "elliott");
2537        assert_eq!(body["personal_essentials"].as_array().unwrap().len(), 1);
2538        assert_eq!(
2539            body["personal_essentials"][0]["content"],
2540            "personal essential"
2541        );
2542        assert_eq!(body["doctrine"].as_array().unwrap().len(), 1);
2543        assert_eq!(body["doctrine"][0]["content"], "doctrine baseline");
2544        assert_eq!(body["doctrine"][0]["source"], "Doctrine");
2545    }
2546
2547    #[tokio::test]
2548    async fn knowledge_graph_add_query_invalidate() {
2549        let (app, auth) = app_with_store().await;
2550        let write = bearer(&auth, "elliott", "knowledge:write");
2551        let read = bearer(&auth, "elliott", "knowledge:read");
2552
2553        // Add a triple.
2554        let res = app
2555            .clone()
2556            .oneshot(
2557                Request::builder()
2558                    .method("POST")
2559                    .uri("/kg/triples")
2560                    .header("authorization", &write)
2561                    .header("content-type", "application/json")
2562                    .body(Body::from(
2563                        serde_json::json!({
2564                            "subject": "Ijima",
2565                            "predicate": "depends_on",
2566                            "object": "SurrealDB",
2567                            "confidence": 1.0,
2568                        })
2569                        .to_string(),
2570                    ))
2571                    .unwrap(),
2572            )
2573            .await
2574            .unwrap();
2575        assert_eq!(res.status(), StatusCode::OK);
2576
2577        // Query the entity — outgoing edge present.
2578        let res = app
2579            .clone()
2580            .oneshot(
2581                Request::builder()
2582                    .uri("/kg/entities/Ijima")
2583                    .header("authorization", &read)
2584                    .body(Body::empty())
2585                    .unwrap(),
2586            )
2587            .await
2588            .unwrap();
2589        assert_eq!(res.status(), StatusCode::OK);
2590        let body: serde_json::Value = serde_json::from_slice(
2591            &axum::body::to_bytes(res.into_body(), usize::MAX)
2592                .await
2593                .unwrap(),
2594        )
2595        .unwrap();
2596        assert_eq!(body["outgoing"].as_array().unwrap().len(), 1);
2597        assert_eq!(body["outgoing"][0]["object"], "SurrealDB");
2598        assert!(body["incoming"].as_array().unwrap().is_empty());
2599
2600        // Stats.
2601        let res = app
2602            .clone()
2603            .oneshot(
2604                Request::builder()
2605                    .uri("/kg/stats")
2606                    .header("authorization", &read)
2607                    .body(Body::empty())
2608                    .unwrap(),
2609            )
2610            .await
2611            .unwrap();
2612        let body: serde_json::Value = serde_json::from_slice(
2613            &axum::body::to_bytes(res.into_body(), usize::MAX)
2614                .await
2615                .unwrap(),
2616        )
2617        .unwrap();
2618        assert_eq!(body["entities"], 2);
2619        assert_eq!(body["triples"], 1);
2620
2621        // Invalidate.
2622        let res = app
2623            .oneshot(
2624                Request::builder()
2625                    .method("POST")
2626                    .uri("/kg/triples/Ijima:depends_on:SurrealDB/invalidate")
2627                    .header("authorization", &write)
2628                    .body(Body::empty())
2629                    .unwrap(),
2630            )
2631            .await
2632            .unwrap();
2633        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2634    }
2635
2636    #[tokio::test]
2637    async fn status_requires_admin_and_reports_counts() {
2638        let (app, auth) = app_with_store().await;
2639        let admin = bearer(&auth, "op", "admin");
2640        let read = bearer(&auth, "user", MEMORY_READ);
2641
2642        // Store a memory + a triple so counts are non-zero.
2643        let _ = app
2644            .clone()
2645            .oneshot(
2646                Request::builder()
2647                    .method("POST")
2648                    .uri("/memories")
2649                    .header("authorization", &admin)
2650                    .header("content-type", "application/json")
2651                    .body(Body::from(
2652                        serde_json::json!({
2653                            "id": "m1",
2654                            "content": "stat test",
2655                            "project": "x",
2656                            "topic": "x",
2657                            "source": "Explicit",
2658                            "harness": "Pi",
2659                        })
2660                        .to_string(),
2661                    ))
2662                    .unwrap(),
2663            )
2664            .await
2665            .unwrap();
2666
2667        // Non-admin is forbidden.
2668        let res = app
2669            .clone()
2670            .oneshot(
2671                Request::builder()
2672                    .uri("/status")
2673                    .header("authorization", &read)
2674                    .body(Body::empty())
2675                    .unwrap(),
2676            )
2677            .await
2678            .unwrap();
2679        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2680
2681        // Admin sees global counts.
2682        let res = app
2683            .oneshot(
2684                Request::builder()
2685                    .uri("/status")
2686                    .header("authorization", &admin)
2687                    .body(Body::empty())
2688                    .unwrap(),
2689            )
2690            .await
2691            .unwrap();
2692        assert_eq!(res.status(), StatusCode::OK);
2693        let body: serde_json::Value = serde_json::from_slice(
2694            &axum::body::to_bytes(res.into_body(), usize::MAX)
2695                .await
2696                .unwrap(),
2697        )
2698        .unwrap();
2699        assert_eq!(body["memories"], 1);
2700        // Deploy-kit fields: version pinned to the crate version, sane
2701        // uptime, real start time.
2702        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2703        let uptime = body["uptime_secs"].as_u64().expect("uptime is u64");
2704        assert!(uptime < 60, "fresh test app should have tiny uptime");
2705        assert!(
2706            body["started_at_unix"].as_u64().expect("started_at is u64") > 1_000_000_000,
2707            "started_at looks like a unix timestamp"
2708        );
2709        assert!(!body["namespaces"].as_array().unwrap().is_empty());
2710    }
2711
2712    #[tokio::test]
2713    async fn sessions_create_list_end_via_http() {
2714        let (app, auth) = app_with_store().await;
2715        let ingest = bearer(&auth, "op", SESSION_INGEST);
2716        let read = bearer(&auth, "op", MEMORY_READ);
2717
2718        // Create two sessions.
2719        for (id, harness) in [("sess_a", "Pi"), ("sess_b", "Sakamoto")] {
2720            let res = app
2721                .clone()
2722                .oneshot(
2723                    Request::builder()
2724                        .method("POST")
2725                        .uri("/sessions")
2726                        .header("authorization", &ingest)
2727                        .header("content-type", "application/json")
2728                        .body(Body::from(
2729                            serde_json::json!({
2730                                "id": id,
2731                                "harness": harness,
2732                                "channel": "thread-1",
2733                                "started_at": "2026-07-05T10:00:00Z",
2734                            })
2735                            .to_string(),
2736                        ))
2737                        .unwrap(),
2738                )
2739                .await
2740                .unwrap();
2741            assert_eq!(res.status(), StatusCode::OK);
2742        }
2743
2744        // List — both present.
2745        let res = app
2746            .clone()
2747            .oneshot(
2748                Request::builder()
2749                    .uri("/sessions")
2750                    .header("authorization", &read)
2751                    .body(Body::empty())
2752                    .unwrap(),
2753            )
2754            .await
2755            .unwrap();
2756        assert_eq!(res.status(), StatusCode::OK);
2757        let body: serde_json::Value = serde_json::from_slice(
2758            &axum::body::to_bytes(res.into_body(), usize::MAX)
2759                .await
2760                .unwrap(),
2761        )
2762        .unwrap();
2763        let arr = body.as_array().unwrap();
2764        assert_eq!(arr.len(), 2);
2765
2766        // Filter by harness=pi.
2767        let res = app
2768            .clone()
2769            .oneshot(
2770                Request::builder()
2771                    .uri("/sessions?harness=pi")
2772                    .header("authorization", &read)
2773                    .body(Body::empty())
2774                    .unwrap(),
2775            )
2776            .await
2777            .unwrap();
2778        let body: serde_json::Value = serde_json::from_slice(
2779            &axum::body::to_bytes(res.into_body(), usize::MAX)
2780                .await
2781                .unwrap(),
2782        )
2783        .unwrap();
2784        assert_eq!(body.as_array().unwrap().len(), 1);
2785        assert_eq!(body[0]["harness"], "Pi");
2786
2787        // End sess_a.
2788        let res = app
2789            .clone()
2790            .oneshot(
2791                Request::builder()
2792                    .method("POST")
2793                    .uri("/sessions/sess_a/end")
2794                    .header("authorization", &ingest)
2795                    .header("content-type", "application/json")
2796                    .body(Body::from(
2797                        serde_json::json!({ "ended_at": "2026-07-05T11:00:00Z" }).to_string(),
2798                    ))
2799                    .unwrap(),
2800            )
2801            .await
2802            .unwrap();
2803        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2804
2805        // Verify ended_at is persisted.
2806        let res = app
2807            .oneshot(
2808                Request::builder()
2809                    .uri("/sessions?harness=pi")
2810                    .header("authorization", &read)
2811                    .body(Body::empty())
2812                    .unwrap(),
2813            )
2814            .await
2815            .unwrap();
2816        let body: serde_json::Value = serde_json::from_slice(
2817            &axum::body::to_bytes(res.into_body(), usize::MAX)
2818                .await
2819                .unwrap(),
2820        )
2821        .unwrap();
2822        assert_eq!(body[0]["ended_at"], "2026-07-05T11:00:00Z");
2823    }
2824
2825    #[tokio::test]
2826    async fn mining_queue_requires_review_capability() {
2827        let (app, auth) = app_with_store().await;
2828        let reviewer = bearer(&auth, "op", MINING_REVIEW);
2829        let reader = bearer(&auth, "op", MEMORY_READ);
2830
2831        // A memory:read holder cannot list the queue.
2832        let res = app
2833            .clone()
2834            .oneshot(
2835                Request::builder()
2836                    .uri("/mining/queue")
2837                    .header("authorization", &reader)
2838                    .body(Body::empty())
2839                    .unwrap(),
2840            )
2841            .await
2842            .unwrap();
2843        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2844
2845        // A mining:review holder can list (empty queue).
2846        let res = app
2847            .oneshot(
2848                Request::builder()
2849                    .uri("/mining/queue")
2850                    .header("authorization", &reviewer)
2851                    .body(Body::empty())
2852                    .unwrap(),
2853            )
2854            .await
2855            .unwrap();
2856        assert_eq!(res.status(), StatusCode::OK);
2857        let body: serde_json::Value = serde_json::from_slice(
2858            &axum::body::to_bytes(res.into_body(), usize::MAX)
2859                .await
2860                .unwrap(),
2861        )
2862        .unwrap();
2863        assert!(body.as_array().unwrap().is_empty());
2864    }
2865
2866    fn hit_mem(id: &str, sim: f32) -> SearchHit {
2867        SearchHit {
2868            memory: Memory {
2869                id: MemoryId(id.into()),
2870                content: id.into(),
2871                project: "p".into(),
2872                topic: "t".into(),
2873                source: ijima_core::MemorySource::Explicit,
2874                harness: ijima_core::harness::Harness::Pi,
2875                session_id: None,
2876                origin: ijima_core::InstanceId::local(),
2877                authority: ijima_core::AuthorityScope::local(),
2878                importance: 0.5,
2879                created_at: "0".into(),
2880            },
2881            similarity: sim,
2882        }
2883    }
2884
2885    #[test]
2886    fn merge_search_hits_ranks_desc_dedups_and_truncates() {
2887        // scope=visible merge: two ranked lists combine by similarity, dedup
2888        // by memory id (first wins), truncate to limit.
2889        let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.5)];
2890        let b = vec![hit_mem("c", 0.8), hit_mem("a", 0.7)]; // 'a' dup, lower sim
2891        let merged = merge_search_hits(a, b, 3);
2892        // Sorted by similarity desc: a(0.9), c(0.8), b(0.5) — the dup a(0.7)
2893        // is dropped (first wins).
2894        assert_eq!(merged.len(), 3);
2895        assert_eq!(merged[0].memory.id.0, "a");
2896        assert_eq!((merged[0].similarity * 10.0).round() as i32, 9);
2897        assert_eq!(merged[1].memory.id.0, "c");
2898        assert_eq!(merged[2].memory.id.0, "b");
2899    }
2900
2901    #[test]
2902    fn merge_search_hits_respects_limit() {
2903        let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.8)];
2904        let b = vec![hit_mem("c", 0.7), hit_mem("d", 0.6)];
2905        let merged = merge_search_hits(a, b, 2);
2906        assert_eq!(merged.len(), 2);
2907        assert_eq!(merged[0].memory.id.0, "a");
2908        assert_eq!(merged[1].memory.id.0, "b");
2909    }
2910
2911    #[cfg(feature = "mining")]
2912    #[tokio::test]
2913    async fn trigger_requires_mining_trigger_capability() {
2914        let (app, auth) = app_with_store().await;
2915        // A memory:write holder cannot trigger mining.
2916        let write = bearer(&auth, "elliott", MEMORY_WRITE);
2917        let res = app
2918            .oneshot(
2919                Request::builder()
2920                    .method("POST")
2921                    .uri("/sessions/sess_x/mine")
2922                    .header("authorization", &write)
2923                    .body(Body::empty())
2924                    .unwrap(),
2925            )
2926            .await
2927            .unwrap();
2928        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2929    }
2930
2931    #[cfg(feature = "mining")]
2932    #[tokio::test]
2933    async fn trigger_mines_decision_and_archives() {
2934        // Rules-only: assumes no IJIMA_LLM_* env is set (CI is clean). When
2935        // env is unset, `build_mining_agent` returns None and `mine_all` runs
2936        // the deterministic rules tier.
2937        let (app, auth) = app_with_store().await;
2938        let ingest = bearer(&auth, "elliott", SESSION_INGEST);
2939        let trigger = bearer(&auth, "elliott", MINING_TRIGGER);
2940
2941        // Ingest a decision-bearing turn into elliott's personal namespace.
2942        let turn = serde_json::json!({
2943            "session_id": "sess_mine",
2944            "turn_index": 0,
2945            "role": "User",
2946            "content": "We decided to use SurrealDB for storage.",
2947            "timestamp": "0",
2948        });
2949        let res = app
2950            .clone()
2951            .oneshot(
2952                Request::builder()
2953                    .method("POST")
2954                    .uri("/sessions/sess_mine/turns")
2955                    .header("authorization", &ingest)
2956                    .header("content-type", "application/json")
2957                    .body(Body::from(turn.to_string()))
2958                    .unwrap(),
2959            )
2960            .await
2961            .unwrap();
2962        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2963
2964        // Trigger mining (rules-only: no IJIMA_LLM_* env in tests).
2965        let res = app
2966            .oneshot(
2967                Request::builder()
2968                    .method("POST")
2969                    .uri("/sessions/sess_mine/mine")
2970                    .header("authorization", &trigger)
2971                    .body(Body::empty())
2972                    .unwrap(),
2973            )
2974            .await
2975            .unwrap();
2976        assert_eq!(res.status(), StatusCode::OK);
2977        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2978            .await
2979            .unwrap();
2980        let report: crate::mining_pipeline::MiningReport = serde_json::from_slice(&body).unwrap();
2981        assert!(
2982            report.archived >= 1,
2983            "rules tier should archive the decision: {report:?}"
2984        );
2985    }
2986
2987    // ===== Palace / diary / repo route tests (Phase B) =====
2988
2989    async fn body_json(res: axum::response::Response) -> serde_json::Value {
2990        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2991            .await
2992            .unwrap();
2993        serde_json::from_slice(&body).unwrap()
2994    }
2995
2996    async fn seed_memory(app: &Router, auth: &IjimaAuth, id: &str, project: &str, topic: &str) {
2997        let body = serde_json::json!({
2998            "id": id,
2999            "content": format!("{project}/{topic} note"),
3000            "project": project,
3001            "topic": topic,
3002            "source": "Explicit",
3003            "harness": "Pi",
3004            "session_id": "sess_1",
3005            "importance": 0.5,
3006            "created_at": "0",
3007        })
3008        .to_string();
3009        let res = app
3010            .clone()
3011            .oneshot(
3012                Request::builder()
3013                    .method("POST")
3014                    .uri("/memories")
3015                    .header("authorization", bearer(auth, "elliott", MEMORY_WRITE))
3016                    .header("content-type", "application/json")
3017                    .body(Body::from(body))
3018                    .unwrap(),
3019            )
3020            .await
3021            .unwrap();
3022        assert_eq!(res.status(), StatusCode::OK, "seed {id} failed");
3023    }
3024
3025    #[tokio::test]
3026    async fn store_memory_honors_namespace_query() {
3027        let (app, auth) = app_with_store().await;
3028        let body = serde_json::json!({
3029            "id": "mem_nsimp",
3030            "content": "imported via namespace query",
3031            "project": "ijima",
3032            "topic": "import",
3033            "source": "AutoCapture",
3034            "harness": "Pi",
3035            "importance": 0.5,
3036            "created_at": "0",
3037        })
3038        .to_string();
3039        let res = app
3040            .clone()
3041            .oneshot(
3042                Request::builder()
3043                    .method("POST")
3044                    .uri("/memories?namespace=ns_import_testbox")
3045                    .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3046                    .header("content-type", "application/json")
3047                    .body(Body::from(body))
3048                    .unwrap(),
3049            )
3050            .await
3051            .unwrap();
3052        assert_eq!(res.status(), StatusCode::OK);
3053
3054        // Dedup check in that namespace finds it; the caller's personal
3055        // namespace does not (isolation held).
3056        let read_token = bearer(&auth, "elliott", MEMORY_READ);
3057        let check = |uri: &str| {
3058            let uri = uri.to_string();
3059            let app = app.clone();
3060            let body = serde_json::json!({
3061                "content": "imported via namespace query"
3062            })
3063            .to_string();
3064            let auth_header = read_token.clone();
3065            async move {
3066                app.oneshot(
3067                    Request::builder()
3068                        .method("POST")
3069                        .uri(uri)
3070                        .header("authorization", auth_header)
3071                        .header("content-type", "application/json")
3072                        .body(Body::from(body))
3073                        .unwrap(),
3074                )
3075                .await
3076                .unwrap()
3077            }
3078        };
3079        let res = check("/memories/check?namespace=ns_import_testbox").await;
3080        assert_eq!(res.status(), StatusCode::OK);
3081        let found = body_json(res).await;
3082        assert_eq!(
3083            found["duplicate"].as_str(),
3084            Some("mem_nsimp"),
3085            "same-namespace dedup check must find the import"
3086        );
3087        let res = check("/memories/check").await;
3088        assert_eq!(res.status(), StatusCode::OK);
3089        let personal = body_json(res).await;
3090        assert_eq!(
3091            personal["duplicate"].as_str(),
3092            None,
3093            "personal namespace must not see the import"
3094        );
3095    }
3096
3097    #[tokio::test]
3098    async fn store_memory_rejects_foreign_private_namespace() {
3099        let (app, auth) = app_with_store().await;
3100        let body = serde_json::json!({
3101            "id": "mem_sneaky",
3102            "content": "cross-tenant write attempt",
3103            "project": "ijima",
3104            "topic": "security",
3105            "source": "Explicit",
3106            "harness": "Pi",
3107            "importance": 0.5,
3108            "created_at": "0",
3109        })
3110        .to_string();
3111        let res = app
3112            .oneshot(
3113                Request::builder()
3114                    .method("POST")
3115                    .uri("/memories?namespace=ns_bob_private")
3116                    .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3117                    .header("content-type", "application/json")
3118                    .body(Body::from(body))
3119                    .unwrap(),
3120            )
3121            .await
3122            .unwrap();
3123        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3124    }
3125
3126    #[tokio::test]
3127    async fn rooms_taxonomy_stats_reflect_seeded_memories() {
3128        let (app, auth) = app_with_store().await;
3129        seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3130        seed_memory(&app, &auth, "mem_b", "ijima", "auth").await;
3131        let read = bearer(&auth, "elliott", MEMORY_READ);
3132
3133        // /rooms
3134        let res = app
3135            .clone()
3136            .oneshot(
3137                Request::builder()
3138                    .uri("/rooms")
3139                    .header("authorization", &read)
3140                    .body(Body::empty())
3141                    .unwrap(),
3142            )
3143            .await
3144            .unwrap();
3145        assert_eq!(res.status(), StatusCode::OK);
3146        let rooms = body_json(res).await;
3147        let topics: std::collections::HashSet<&str> = rooms
3148            .as_array()
3149            .unwrap()
3150            .iter()
3151            .map(|r| r["topic"].as_str().unwrap())
3152            .collect();
3153        assert!(
3154            topics.contains("api") && topics.contains("auth"),
3155            "rooms: {rooms}"
3156        );
3157
3158        // /memories/stats
3159        let res = app
3160            .clone()
3161            .oneshot(
3162                Request::builder()
3163                    .uri("/memories/stats")
3164                    .header("authorization", &read)
3165                    .body(Body::empty())
3166                    .unwrap(),
3167            )
3168            .await
3169            .unwrap();
3170        assert_eq!(res.status(), StatusCode::OK);
3171        let stats = body_json(res).await;
3172        assert_eq!(stats["total"], 2, "stats: {stats}");
3173        assert_eq!(stats["projects"][0]["project"], "ijima");
3174        assert_eq!(stats["projects"][0]["count"], 2);
3175    }
3176
3177    #[tokio::test]
3178    async fn browse_memories_filters_by_project() {
3179        let (app, auth) = app_with_store().await;
3180        seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3181        seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3182        let read = bearer(&auth, "elliott", MEMORY_READ);
3183
3184        let res = app
3185            .clone()
3186            .oneshot(
3187                Request::builder()
3188                    .uri("/memories?project=possum")
3189                    .header("authorization", &read)
3190                    .body(Body::empty())
3191                    .unwrap(),
3192            )
3193            .await
3194            .unwrap();
3195        assert_eq!(res.status(), StatusCode::OK);
3196        let mems = body_json(res).await;
3197        let arr = mems.as_array().unwrap();
3198        assert_eq!(arr.len(), 1);
3199        assert_eq!(arr[0]["project"], "possum");
3200    }
3201
3202    #[tokio::test]
3203    async fn palace_graph_and_tunnel_link_shared_topic() {
3204        let (app, auth) = app_with_store().await;
3205        seed_memory(&app, &auth, "mem_a", "ijima", "efficiency").await;
3206        seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3207        let read = bearer(&auth, "elliott", MEMORY_READ);
3208
3209        let res = app
3210            .clone()
3211            .oneshot(
3212                Request::builder()
3213                    .uri("/palace/graph")
3214                    .header("authorization", &read)
3215                    .body(Body::empty())
3216                    .unwrap(),
3217            )
3218            .await
3219            .unwrap();
3220        assert_eq!(res.status(), StatusCode::OK);
3221        let graph = body_json(res).await;
3222        let projects: std::collections::HashSet<&str> = graph["projects"]
3223            .as_array()
3224            .unwrap()
3225            .iter()
3226            .map(|p| p.as_str().unwrap())
3227            .collect();
3228        assert!(
3229            projects.contains("ijima") && projects.contains("possum"),
3230            "graph: {graph}"
3231        );
3232
3233        let res = app
3234            .clone()
3235            .oneshot(
3236                Request::builder()
3237                    .uri("/palace/tunnel?topic=efficiency&project_a=ijima&project_b=possum")
3238                    .header("authorization", &read)
3239                    .body(Body::empty())
3240                    .unwrap(),
3241            )
3242            .await
3243            .unwrap();
3244        assert_eq!(res.status(), StatusCode::OK);
3245        let trav = body_json(res).await;
3246        assert_eq!(trav["memories_a"].as_array().unwrap().len(), 1);
3247        assert_eq!(trav["memories_b"].as_array().unwrap().len(), 1);
3248    }
3249
3250    #[tokio::test]
3251    async fn diary_write_then_read_round_trips() {
3252        let (app, auth) = app_with_store().await;
3253        let write = bearer(&auth, "elliott", MEMORY_WRITE);
3254        let read = bearer(&auth, "elliott", MEMORY_READ);
3255
3256        let body = serde_json::json!({
3257            "agent": "pi",
3258            "content": "shipped the routes",
3259            "topic": "ijima",
3260            "timestamp": "2026-08-09T12:00:00Z"
3261        })
3262        .to_string();
3263        let res = app
3264            .clone()
3265            .oneshot(
3266                Request::builder()
3267                    .method("POST")
3268                    .uri("/diaries")
3269                    .header("authorization", &write)
3270                    .header("content-type", "application/json")
3271                    .body(Body::from(body))
3272                    .unwrap(),
3273            )
3274            .await
3275            .unwrap();
3276        assert_eq!(res.status(), StatusCode::NO_CONTENT);
3277
3278        let res = app
3279            .clone()
3280            .oneshot(
3281                Request::builder()
3282                    .uri("/diaries/pi")
3283                    .header("authorization", &read)
3284                    .body(Body::empty())
3285                    .unwrap(),
3286            )
3287            .await
3288            .unwrap();
3289        assert_eq!(res.status(), StatusCode::OK);
3290        let entries = body_json(res).await;
3291        let arr = entries.as_array().unwrap();
3292        assert_eq!(arr.len(), 1);
3293        assert_eq!(arr[0]["content"], "shipped the routes");
3294    }
3295
3296    #[tokio::test]
3297    async fn diary_write_requires_memory_write_not_read() {
3298        let (app, auth) = app_with_store().await;
3299        let read = bearer(&auth, "elliott", MEMORY_READ);
3300        let body = serde_json::json!({"agent": "pi", "content": "x", "timestamp": "t"}).to_string();
3301        let res = app
3302            .clone()
3303            .oneshot(
3304                Request::builder()
3305                    .method("POST")
3306                    .uri("/diaries")
3307                    .header("authorization", &read)
3308                    .header("content-type", "application/json")
3309                    .body(Body::from(body))
3310                    .unwrap(),
3311            )
3312            .await
3313            .unwrap();
3314        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3315    }
3316
3317    #[tokio::test]
3318    async fn repo_register_list_resolve_round_trips() {
3319        let (app, auth) = app_with_store().await;
3320        let admin = bearer(&auth, "elliott", ADMIN);
3321        let read = bearer(&auth, "elliott", MEMORY_READ);
3322
3323        // register a repo (admin)
3324        let body = serde_json::json!({
3325            "name": "Ijima",
3326            "path": "/home/x/Ijima",
3327            "remote_url": "git@github.com:Industrial-Algebra/Ijima.git",
3328            "role": "memory-service"
3329        })
3330        .to_string();
3331        let res = app
3332            .clone()
3333            .oneshot(
3334                Request::builder()
3335                    .method("POST")
3336                    .uri("/repos")
3337                    .header("authorization", &admin)
3338                    .header("content-type", "application/json")
3339                    .body(Body::from(body))
3340                    .unwrap(),
3341            )
3342            .await
3343            .unwrap();
3344        assert_eq!(res.status(), StatusCode::NO_CONTENT);
3345
3346        // list (memory:read)
3347        let res = app
3348            .clone()
3349            .oneshot(
3350                Request::builder()
3351                    .uri("/repos")
3352                    .header("authorization", &read)
3353                    .body(Body::empty())
3354                    .unwrap(),
3355            )
3356            .await
3357            .unwrap();
3358        assert_eq!(res.status(), StatusCode::OK);
3359        let repos = body_json(res).await;
3360        assert_eq!(repos[0]["name"], "Ijima");
3361        assert_eq!(repos[0]["path"], "/home/x/Ijima");
3362
3363        // resolve a cwd inside the repo (memory:read)
3364        let res = app
3365            .clone()
3366            .oneshot(
3367                Request::builder()
3368                    .uri("/repos/resolve?cwd=/home/x/Ijima/src")
3369                    .header("authorization", &read)
3370                    .body(Body::empty())
3371                    .unwrap(),
3372            )
3373            .await
3374            .unwrap();
3375        assert_eq!(res.status(), StatusCode::OK);
3376        let repo = body_json(res).await;
3377        assert_eq!(repo["name"], "Ijima");
3378
3379        // resolve a cwd in no registered repo → 404
3380        let res = app
3381            .clone()
3382            .oneshot(
3383                Request::builder()
3384                    .uri("/repos/resolve?cwd=/nowhere/here")
3385                    .header("authorization", &read)
3386                    .body(Body::empty())
3387                    .unwrap(),
3388            )
3389            .await
3390            .unwrap();
3391        assert_eq!(res.status(), StatusCode::NOT_FOUND);
3392    }
3393
3394    #[tokio::test]
3395    async fn repo_register_requires_admin() {
3396        let (app, auth) = app_with_store().await;
3397        let read = bearer(&auth, "elliott", MEMORY_READ);
3398        let body = serde_json::json!({
3399            "name": "X", "path": "/x", "remote_url": "u", "role": "r"
3400        })
3401        .to_string();
3402        let res = app
3403            .clone()
3404            .oneshot(
3405                Request::builder()
3406                    .method("POST")
3407                    .uri("/repos")
3408                    .header("authorization", &read)
3409                    .header("content-type", "application/json")
3410                    .body(Body::from(body))
3411                    .unwrap(),
3412            )
3413            .await
3414            .unwrap();
3415        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3416    }
3417
3418    #[tokio::test]
3419    async fn token_revocation_kills_the_bearer_immediately() {
3420        let (app, auth) = app_with_store().await;
3421        let admin = bearer(&auth, "op", ADMIN);
3422        let victim = bearer(&auth, "victim", MEMORY_READ);
3423
3424        // Victim can read before revocation.
3425        let res = app
3426            .clone()
3427            .oneshot(
3428                Request::builder()
3429                    .uri("/memories")
3430                    .header("authorization", &victim)
3431                    .body(Body::empty())
3432                    .unwrap(),
3433            )
3434            .await
3435            .unwrap();
3436        assert_eq!(res.status(), StatusCode::OK);
3437
3438        // Non-admin cannot revoke.
3439        let res = app
3440            .clone()
3441            .oneshot(
3442                Request::builder()
3443                    .method("POST")
3444                    .uri("/tokens/revoke")
3445                    .header("authorization", &victim)
3446                    .header("content-type", "application/json")
3447                    .body(Body::from(
3448                        serde_json::json!({ "token": victim, "reason": "test" }).to_string(),
3449                    ))
3450                    .unwrap(),
3451            )
3452            .await
3453            .unwrap();
3454        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3455
3456        // Admin revokes the victim's bearer.
3457        let res = app
3458            .clone()
3459            .oneshot(
3460                Request::builder()
3461                    .method("POST")
3462                    .uri("/tokens/revoke")
3463                    .header("authorization", &admin)
3464                    .header("content-type", "application/json")
3465                    .body(Body::from(
3466                        serde_json::json!({ "token": victim, "reason": "leaked in test" })
3467                            .to_string(),
3468                    ))
3469                    .unwrap(),
3470            )
3471            .await
3472            .unwrap();
3473        assert_eq!(res.status(), StatusCode::NO_CONTENT);
3474
3475        // The same bearer is now exactly as dead as a bad signature.
3476        let res = app
3477            .clone()
3478            .oneshot(
3479                Request::builder()
3480                    .uri("/memories")
3481                    .header("authorization", &victim)
3482                    .body(Body::empty())
3483                    .unwrap(),
3484            )
3485            .await
3486            .unwrap();
3487        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
3488
3489        // Admin can list the revocation — hash only, never the bearer.
3490        let res = app
3491            .clone()
3492            .oneshot(
3493                Request::builder()
3494                    .uri("/tokens/revocations")
3495                    .header("authorization", &admin)
3496                    .body(Body::empty())
3497                    .unwrap(),
3498            )
3499            .await
3500            .unwrap();
3501        assert_eq!(res.status(), StatusCode::OK);
3502        let body: serde_json::Value = serde_json::from_slice(
3503            &axum::body::to_bytes(res.into_body(), usize::MAX)
3504                .await
3505                .unwrap(),
3506        )
3507        .unwrap();
3508        let revs = body.as_array().expect("list response");
3509        assert_eq!(revs.len(), 1);
3510        assert_eq!(revs[0]["reason"], "leaked in test");
3511        assert_eq!(
3512            revs[0]["token_hash"].as_str().expect("hash"),
3513            crate::auth::bearer_hash(&victim)
3514        );
3515        assert!(!revs[0].to_string().contains(&victim), "no raw bearer");
3516
3517        // Revocation survives restart-by-rehydration: a fresh auth over
3518        // the same store re-arms (simulated via hydrate from the store).
3519        let listed: Vec<TokenRevocation> =
3520            serde_json::from_value(body).expect("deserializes as TokenRevocation");
3521        auth.hydrate_revocations(&listed);
3522        assert!(auth.is_revoked(&victim));
3523    }
3524}