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    Query(q): Query<NsQuery>,
809    Json(req): Json<AddTripleRequest>,
810) -> Result<Json<ijima_core::Triple>, ApiError> {
811    if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
812        return Err(ApiError::Forbidden);
813    }
814    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
815    let triple = kg
816        .add_triple(
817            &ns,
818            EntityId::new(req.subject),
819            &req.predicate,
820            EntityId::new(req.object),
821            req.valid_from.as_deref(),
822            req.confidence.unwrap_or(1.0),
823            req.source_memory_id.as_deref(),
824        )
825        .await
826        .map_err(internal)?;
827    // Touch `store` so the Extension is consumed.
828    let _ = store;
829    Ok(Json(triple))
830}
831
832async fn query_entity(
833    principal: AuthPrincipal,
834    Extension(store): Extension<Arc<dyn Store>>,
835    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
836    Path(id): Path<String>,
837    Query(q): Query<NsQuery>,
838) -> Result<Json<ijima_core::EntityRecord>, ApiError> {
839    if !principal.0.may(KNOWLEDGE_READ) {
840        return Err(ApiError::Forbidden);
841    }
842    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
843    let rec = kg
844        .query_entity(&ns, &EntityId::new(id))
845        .await
846        .map_err(internal)?;
847    Ok(Json(rec))
848}
849
850async fn invalidate_triple(
851    principal: AuthPrincipal,
852    Extension(store): Extension<Arc<dyn Store>>,
853    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
854    Path(id): Path<String>,
855    Query(q): Query<NsQuery>,
856) -> Result<StatusCode, ApiError> {
857    if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
858        return Err(ApiError::Forbidden);
859    }
860    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
861    kg.invalidate_triple(&ns, &id).await.map_err(internal)?;
862    Ok(StatusCode::NO_CONTENT)
863}
864
865#[derive(Deserialize, Default)]
866struct FindTriplesQuery {
867    namespace: Option<String>,
868    subject: Option<String>,
869    predicate: Option<String>,
870    object: Option<String>,
871}
872
873async fn find_triples(
874    principal: AuthPrincipal,
875    Extension(store): Extension<Arc<dyn Store>>,
876    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
877    Query(q): Query<FindTriplesQuery>,
878) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
879    if !principal.0.may(KNOWLEDGE_READ) {
880        return Err(ApiError::Forbidden);
881    }
882    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
883    let triples = kg
884        .find_triples(
885            &ns,
886            q.subject.as_deref().map(EntityId::new).as_ref(),
887            q.predicate.as_deref(),
888            q.object.as_deref().map(EntityId::new).as_ref(),
889        )
890        .await
891        .map_err(internal)?;
892    Ok(Json(triples))
893}
894
895async fn kg_timeline(
896    principal: AuthPrincipal,
897    Extension(store): Extension<Arc<dyn Store>>,
898    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
899    Query(q): Query<NsQuery>,
900) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
901    if !principal.0.may(KNOWLEDGE_READ) {
902        return Err(ApiError::Forbidden);
903    }
904    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
905    let triples = kg
906        .kg_timeline(&ns, q.limit.unwrap_or(50))
907        .await
908        .map_err(internal)?;
909    Ok(Json(triples))
910}
911
912async fn kg_stats(
913    principal: AuthPrincipal,
914    Extension(store): Extension<Arc<dyn Store>>,
915    Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
916    Query(q): Query<NsQuery>,
917) -> Result<Json<ijima_core::KgStats>, ApiError> {
918    if !principal.0.may(KNOWLEDGE_READ) {
919        return Err(ApiError::Forbidden);
920    }
921    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
922    let stats = kg.knowledge_stats(&ns).await.map_err(internal)?;
923    Ok(Json(stats))
924}
925
926async fn ingest_turn(
927    principal: AuthPrincipal,
928    Extension(store): Extension<Arc<dyn Store>>,
929    Path(session_id): Path<String>,
930    Json(mut turn): Json<SessionTurn>,
931) -> Result<StatusCode, ApiError> {
932    if !principal.0.may(SESSION_INGEST) {
933        return Err(ApiError::Forbidden);
934    }
935    let ns = principal.0.personal_namespace();
936    turn.session_id = SessionId::new(session_id);
937    store.ingest_turn(&ns, turn).await.map_err(internal)?;
938    Ok(StatusCode::NO_CONTENT)
939}
940
941// TurnsQuery is unified into NsQuery above.
942
943#[derive(Serialize)]
944struct TurnsResponse {
945    turns: Vec<SessionTurn>,
946}
947
948async fn session_turns(
949    principal: AuthPrincipal,
950    Extension(store): Extension<Arc<dyn Store>>,
951    Path(session_id): Path<String>,
952    Query(q): Query<NsQuery>,
953) -> Result<Json<TurnsResponse>, ApiError> {
954    if !principal.0.may(MEMORY_READ) {
955        return Err(ApiError::Forbidden);
956    }
957    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
958    let turns = store
959        .session_turns(&ns, &SessionId::new(session_id), q.limit.unwrap_or(50))
960        .await
961        .map_err(internal)?;
962    Ok(Json(TurnsResponse { turns }))
963}
964
965/// Creates (or upserts) a session's metadata. `ended_at` is forced to
966/// `None` on create — use `POST /sessions/:id/end` to close a session.
967/// Auth: `session:ingest`. The session is stored in the caller's
968/// personal namespace (matching turn ingest).
969async fn create_session(
970    principal: AuthPrincipal,
971    Extension(store): Extension<Arc<dyn Store>>,
972    Json(mut session): Json<Session>,
973) -> Result<Json<IdResponse>, ApiError> {
974    if !principal.0.may(SESSION_INGEST) {
975        return Err(ApiError::Forbidden);
976    }
977    let ns = principal.0.personal_namespace();
978    if session.started_at.is_empty() {
979        session.started_at = std::time::SystemTime::now()
980            .duration_since(std::time::UNIX_EPOCH)
981            .map(|d| d.as_secs().to_string())
982            .unwrap_or_default();
983    }
984    session.ended_at = None;
985    let id = store.create_session(&ns, session).await.map_err(internal)?;
986    Ok(Json(IdResponse { id: id.0 }))
987}
988
989#[derive(Deserialize)]
990struct SessionListQuery {
991    namespace: Option<String>,
992    /// Optional harness filter (wire string, e.g. `pi`).
993    harness: Option<String>,
994    limit: Option<usize>,
995}
996
997/// Lists sessions in the effective namespace, newest first, optionally
998/// filtered by harness. Auth: `memory:read` (session metadata is
999/// read via the same capability as memory palace reads).
1000async fn list_sessions(
1001    principal: AuthPrincipal,
1002    Extension(store): Extension<Arc<dyn Store>>,
1003    Query(q): Query<SessionListQuery>,
1004) -> Result<Json<Vec<Session>>, ApiError> {
1005    if !principal.0.may(MEMORY_READ) {
1006        return Err(ApiError::Forbidden);
1007    }
1008    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1009    let harness = q.harness.as_deref().map(Harness::from_wire_str);
1010    let limit = q.limit.unwrap_or(50).min(500);
1011    let sessions = store
1012        .list_sessions(&ns, harness.as_ref(), limit)
1013        .await
1014        .map_err(internal)?;
1015    Ok(Json(sessions))
1016}
1017
1018#[derive(Deserialize)]
1019struct EndSessionRequest {
1020    ended_at: String,
1021}
1022
1023/// Marks a session as ended. Scoped to the caller's personal namespace.
1024/// Auth: `session:ingest`.
1025async fn end_session(
1026    principal: AuthPrincipal,
1027    Extension(store): Extension<Arc<dyn Store>>,
1028    Path(session_id): Path<String>,
1029    Json(req): Json<EndSessionRequest>,
1030) -> Result<StatusCode, ApiError> {
1031    if !principal.0.may(SESSION_INGEST) {
1032        return Err(ApiError::Forbidden);
1033    }
1034    let ns = principal.0.personal_namespace();
1035    store
1036        .end_session(&ns, &SessionId::new(session_id), req.ended_at)
1037        .await
1038        .map_err(internal)?;
1039    Ok(StatusCode::NO_CONTENT)
1040}
1041
1042// ---------- mining review queue (ADR M2, M3) ----------
1043
1044/// Lists pending mining extractions in the effective namespace, newest
1045/// first. Auth: `mining:review`.
1046async fn list_pending(
1047    principal: AuthPrincipal,
1048    Extension(store): Extension<Arc<dyn Store>>,
1049    Query(q): Query<NsQuery>,
1050) -> Result<Json<Vec<QueuedExtraction>>, ApiError> {
1051    if !principal.0.may(MINING_REVIEW) {
1052        return Err(ApiError::Forbidden);
1053    }
1054    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1055    let limit = q.limit.unwrap_or(50).min(500);
1056    let pending = store.list_pending(&ns, limit).await.map_err(internal)?;
1057    Ok(Json(pending))
1058}
1059
1060/// Accepts a queued extraction: promotes it to the palace and removes it
1061/// from the queue. Auth: `mining:review`.
1062async fn accept_extraction(
1063    principal: AuthPrincipal,
1064    Extension(store): Extension<Arc<dyn Store>>,
1065    Path(id): Path<String>,
1066) -> Result<Json<AcceptedExtraction>, ApiError> {
1067    if !principal.0.may(MINING_REVIEW) {
1068        return Err(ApiError::Forbidden);
1069    }
1070    let ns = principal.0.personal_namespace();
1071    let accepted = store.accept_extraction(&ns, &id).await.map_err(internal)?;
1072    Ok(Json(accepted))
1073}
1074
1075/// Rejects a queued extraction: drops it without promoting. Auth:
1076/// `mining:review`. Returns 204.
1077async fn reject_extraction(
1078    principal: AuthPrincipal,
1079    Extension(store): Extension<Arc<dyn Store>>,
1080    Path(id): Path<String>,
1081) -> Result<StatusCode, ApiError> {
1082    if !principal.0.may(MINING_REVIEW) {
1083        return Err(ApiError::Forbidden);
1084    }
1085    let ns = principal.0.personal_namespace();
1086    store.reject_extraction(&ns, &id).await.map_err(internal)?;
1087    Ok(StatusCode::NO_CONTENT)
1088}
1089
1090// ---------- mining trigger (ADR M1, M3, M7) ----------
1091
1092/// Triggers an extraction pass over a session's turns: runs the rules tier
1093/// (always) plus the llm tier when `IJIMA_LLM_*` is configured, merges +
1094/// content-dedups, then ingests — `Auto` extractions archive to the palace,
1095/// `PendingReview` stage in the review queue. Auth: `mining:trigger`.
1096///
1097/// The llm agent's `HttpAgent::respond` blocks on its own tokio runtime, so
1098/// the synchronous `mine_all` pass runs on a blocking thread (via
1099/// [`tokio::task::spawn_blocking`]) to avoid a runtime-in-runtime panic
1100/// inside this async handler. The concrete [`HttpAgent`] is `Send`; the
1101/// `&mut dyn Agent` coercion happens *inside* the closure, so it never
1102/// crosses the spawn boundary as an unsized non-`Send` trait object.
1103#[cfg(feature = "mining")]
1104async fn trigger_mine(
1105    principal: AuthPrincipal,
1106    Extension(store): Extension<Arc<dyn Store>>,
1107    Path(session_id): Path<String>,
1108    Query(q): Query<NsQuery>,
1109) -> Result<Json<crate::mining_pipeline::MiningReport>, ApiError> {
1110    use proserpina_agent::http::HttpAgent;
1111
1112    if !principal.0.may(MINING_TRIGGER) {
1113        return Err(ApiError::Forbidden);
1114    }
1115    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1116
1117    // Fetch the session's turns (a generous limit — v0 mines the whole session).
1118    let turns = store
1119        .session_turns(&ns, &SessionId::new(session_id.clone()), 10_000)
1120        .await
1121        .map_err(internal)?;
1122    let turn_texts: Vec<String> = turns.into_iter().map(|t| t.content).collect();
1123    let ctx = crate::mining_pipeline::mining_context(&session_id, "general", Harness::Other);
1124
1125    // The extraction pass is synchronous (ADR M1); the llm agent bridges to
1126    // async HTTP internally via its own runtime + `block_on`. Run it on a
1127    // blocking thread so that `block_on` is legal (we are outside any async
1128    // executor here). `build_mining_agent` returns a concrete `Option<HttpAgent>`
1129    // — kept as the concrete type (not a trait object) so it stays `Send` for
1130    // the move into the spawned task.
1131    let extractions = tokio::task::spawn_blocking(move || {
1132        let mut agent: Option<HttpAgent> = build_mining_agent();
1133        let agent_dyn: Option<&mut dyn proserpina_agent::Agent> = agent
1134            .as_mut()
1135            .map(|a| a as &mut dyn proserpina_agent::Agent);
1136        ijima_miner::mine_all(&turn_texts, &ctx, agent_dyn)
1137    })
1138    .await
1139    .map_err(|e| {
1140        internal(ijima_core::IjimaError::Mining {
1141            detail: format!("extraction task failed: {e}"),
1142        })
1143    })?
1144    .map_err(internal)?;
1145
1146    let report = crate::mining_pipeline::ingest_extractions(store.as_ref(), &ns, extractions)
1147        .await
1148        .map_err(internal)?;
1149    Ok(Json(report))
1150}
1151
1152/// Constructs the llm extraction agent from `IJIMA_LLM_*` env config, or
1153/// `None` when mining should run rules-only (no `IJIMA_LLM_MODEL` /
1154/// `IJIMA_LLM_API_KEY` set). `mine_all(None)` then skips the llm tier.
1155///
1156/// Defaults `IJIMA_LLM_BASE_URL` to the DeepSeek endpoint. The agent uses a
1157/// single "Session Mining Extractor" persona covering both fact and pattern
1158/// extraction; v0 does not vary the agent persona per role (ADR M5,
1159/// single-shot). Returns a concrete [`HttpAgent`] (not a trait object) so it
1160/// remains `Send` for the blocking-thread move.
1161#[cfg(feature = "mining")]
1162fn build_mining_agent() -> Option<proserpina_agent::http::HttpAgent> {
1163    use proserpina_agent::{
1164        AgentId, Persona,
1165        http::{HttpAgent, HttpConfig},
1166    };
1167
1168    let base_url = std::env::var("IJIMA_LLM_BASE_URL")
1169        .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string());
1170    let model = std::env::var("IJIMA_LLM_MODEL").ok()?;
1171    let api_key = std::env::var("IJIMA_LLM_API_KEY").ok()?;
1172
1173    let persona = Persona::new("Session Mining Extractor")
1174        .with_framing(
1175            "You mine session transcripts for durable facts and recurring \
1176             patterns. Output one JSON object per line, each \
1177             {\"content\",\"project\",\"topic\",\"confidence\"}. Omit all \
1178             preamble. If nothing worth extracting, output nothing.",
1179        )
1180        .with_focus(
1181            "decisions, chosen tools, stated constraints, measurements, recurring workflows",
1182        );
1183
1184    Some(HttpAgent::new(
1185        AgentId::new("ijima-miner"),
1186        persona,
1187        HttpConfig {
1188            base_url,
1189            model,
1190            api_key,
1191        },
1192    ))
1193}
1194
1195// ===== Palace organization (memory:read) =====
1196
1197#[derive(Deserialize)]
1198struct NamespaceQuery {
1199    namespace: Option<String>,
1200}
1201
1202#[derive(Deserialize)]
1203struct RoomsQuery {
1204    namespace: Option<String>,
1205    project: Option<String>,
1206    limit: Option<usize>,
1207}
1208
1209#[derive(Deserialize)]
1210struct TunnelQuery {
1211    namespace: Option<String>,
1212    topic: String,
1213    project_a: String,
1214    project_b: String,
1215    limit: Option<usize>,
1216}
1217
1218#[derive(Deserialize)]
1219struct DiaryQuery {
1220    namespace: Option<String>,
1221    limit: Option<usize>,
1222}
1223
1224#[derive(Deserialize)]
1225struct MemoryBrowseQuery {
1226    namespace: Option<String>,
1227    project: Option<String>,
1228    topic: Option<String>,
1229    limit: Option<usize>,
1230}
1231
1232#[derive(Deserialize)]
1233struct ResolveRepoQuery {
1234    cwd: String,
1235}
1236
1237/// Lists rooms (topic cells), optionally filtered to a project. Auth: `memory:read`.
1238async fn list_rooms(
1239    principal: AuthPrincipal,
1240    Extension(store): Extension<Arc<dyn Store>>,
1241    Query(q): Query<RoomsQuery>,
1242) -> Result<Json<Vec<Room>>, ApiError> {
1243    if !principal.0.may(MEMORY_READ) {
1244        return Err(ApiError::Forbidden);
1245    }
1246    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1247    let limit = q.limit.unwrap_or(50).min(500);
1248    let rooms = store
1249        .list_rooms(&ns, q.project.as_deref(), limit)
1250        .await
1251        .map_err(internal)?;
1252    Ok(Json(rooms))
1253}
1254
1255/// Full project → topic → count taxonomy. Auth: `memory:read`.
1256async fn taxonomy(
1257    principal: AuthPrincipal,
1258    Extension(store): Extension<Arc<dyn Store>>,
1259    Query(q): Query<NamespaceQuery>,
1260) -> Result<Json<Vec<ProjectTaxon>>, ApiError> {
1261    if !principal.0.may(MEMORY_READ) {
1262        return Err(ApiError::Forbidden);
1263    }
1264    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1265    Ok(Json(store.taxonomy(&ns).await.map_err(internal)?))
1266}
1267
1268/// The palace graph: projects as nodes, shared-topic tunnels as edges. Auth: `memory:read`.
1269async fn palace_graph(
1270    principal: AuthPrincipal,
1271    Extension(store): Extension<Arc<dyn Store>>,
1272    Query(q): Query<NamespaceQuery>,
1273) -> Result<Json<PalaceGraph>, ApiError> {
1274    if !principal.0.may(MEMORY_READ) {
1275        return Err(ApiError::Forbidden);
1276    }
1277    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1278    Ok(Json(store.palace_graph(&ns).await.map_err(internal)?))
1279}
1280
1281/// Traverses a tunnel — the memories from both projects on a shared topic. Auth: `memory:read`.
1282async fn traverse_tunnel(
1283    principal: AuthPrincipal,
1284    Extension(store): Extension<Arc<dyn Store>>,
1285    Query(q): Query<TunnelQuery>,
1286) -> Result<Json<TunnelTraversal>, ApiError> {
1287    if !principal.0.may(MEMORY_READ) {
1288        return Err(ApiError::Forbidden);
1289    }
1290    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1291    let limit = q.limit.unwrap_or(50).min(500);
1292    Ok(Json(
1293        store
1294            .traverse_tunnel(&ns, &q.topic, &q.project_a, &q.project_b, limit)
1295            .await
1296            .map_err(internal)?,
1297    ))
1298}
1299
1300/// Appends a diary entry to the caller's namespace. Auth: `memory:write`.
1301async fn write_diary(
1302    principal: AuthPrincipal,
1303    Extension(store): Extension<Arc<dyn Store>>,
1304    Json(entry): Json<DiaryEntry>,
1305) -> Result<StatusCode, ApiError> {
1306    if !principal.0.may(MEMORY_WRITE) {
1307        return Err(ApiError::Forbidden);
1308    }
1309    let ns = principal.0.personal_namespace();
1310    store.write_diary(&ns, entry).await.map_err(internal)?;
1311    Ok(StatusCode::NO_CONTENT)
1312}
1313
1314/// Reads `agent`'s diary in the caller's namespace. Auth: `memory:read`.
1315async fn read_diary(
1316    principal: AuthPrincipal,
1317    Extension(store): Extension<Arc<dyn Store>>,
1318    Path(agent): Path<String>,
1319    Query(q): Query<DiaryQuery>,
1320) -> Result<Json<Vec<DiaryEntry>>, ApiError> {
1321    if !principal.0.may(MEMORY_READ) {
1322        return Err(ApiError::Forbidden);
1323    }
1324    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1325    let limit = q.limit.unwrap_or(50).min(500);
1326    Ok(Json(
1327        store
1328            .read_diary(&ns, &agent, limit)
1329            .await
1330            .map_err(internal)?,
1331    ))
1332}
1333
1334/// Browses memories (the `memory_recall` path), optionally filtered to
1335/// project/topic — distinct from the importance-ranked wake-up feed. Auth: `memory:read`.
1336async fn browse_memories(
1337    principal: AuthPrincipal,
1338    Extension(store): Extension<Arc<dyn Store>>,
1339    Query(q): Query<MemoryBrowseQuery>,
1340) -> Result<Json<Vec<Memory>>, ApiError> {
1341    if !principal.0.may(MEMORY_READ) {
1342        return Err(ApiError::Forbidden);
1343    }
1344    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1345    let limit = q.limit.unwrap_or(50).min(500);
1346    Ok(Json(
1347        store
1348            .list_memories_filtered(&ns, q.project.as_deref(), q.topic.as_deref(), limit)
1349            .await
1350            .map_err(internal)?,
1351    ))
1352}
1353
1354#[derive(Serialize)]
1355struct NamespaceStats {
1356    total: usize,
1357    projects: Vec<ProjectCount>,
1358}
1359
1360#[derive(Serialize)]
1361struct ProjectCount {
1362    project: String,
1363    count: usize,
1364}
1365
1366/// Read-accessible namespace stats (derived from room counts; unlike
1367/// `/status` which is admin-gated). Auth: `memory:read`.
1368async fn memory_stats(
1369    principal: AuthPrincipal,
1370    Extension(store): Extension<Arc<dyn Store>>,
1371    Query(q): Query<NamespaceQuery>,
1372) -> Result<Json<NamespaceStats>, ApiError> {
1373    if !principal.0.may(MEMORY_READ) {
1374        return Err(ApiError::Forbidden);
1375    }
1376    let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1377    let rooms = store.list_rooms(&ns, None, 1000).await.map_err(internal)?;
1378    let total: usize = rooms.iter().map(|r| r.count).sum();
1379    let mut by_project: std::collections::BTreeMap<String, usize> =
1380        std::collections::BTreeMap::new();
1381    for r in &rooms {
1382        *by_project.entry(r.project.clone()).or_default() += r.count;
1383    }
1384    let projects = by_project
1385        .into_iter()
1386        .map(|(project, count)| ProjectCount { project, count })
1387        .collect();
1388    Ok(Json(NamespaceStats { total, projects }))
1389}
1390
1391// ===== Repo directory (global registry — Context Mapper) =====
1392
1393/// Registers/upserts a repo in the global registry (operator action). Auth: `admin`.
1394async fn register_repo(
1395    principal: AuthPrincipal,
1396    Extension(store): Extension<Arc<dyn Store>>,
1397    Json(repo): Json<RepoDirectory>,
1398) -> Result<StatusCode, ApiError> {
1399    if !principal.0.may(ADMIN) {
1400        return Err(ApiError::Forbidden);
1401    }
1402    store.register_repo(repo).await.map_err(internal)?;
1403    Ok(StatusCode::NO_CONTENT)
1404}
1405
1406/// Lists every registered repo (the ecosystem roster). Auth: `memory:read`.
1407async fn list_repos(
1408    principal: AuthPrincipal,
1409    Extension(store): Extension<Arc<dyn Store>>,
1410) -> Result<Json<Vec<RepoDirectory>>, ApiError> {
1411    if !principal.0.may(MEMORY_READ) {
1412        return Err(ApiError::Forbidden);
1413    }
1414    Ok(Json(store.list_repos().await.map_err(internal)?))
1415}
1416
1417/// Reverse-resolves a working directory to its registered repo. Auth: `memory:read`.
1418async fn resolve_repo(
1419    principal: AuthPrincipal,
1420    Extension(store): Extension<Arc<dyn Store>>,
1421    Query(q): Query<ResolveRepoQuery>,
1422) -> Result<Json<RepoDirectory>, ApiError> {
1423    if !principal.0.may(MEMORY_READ) {
1424        return Err(ApiError::Forbidden);
1425    }
1426    match store.resolve_repo(&q.cwd).await.map_err(internal)? {
1427        Some(repo) => Ok(Json(repo)),
1428        None => Err(ApiError::NotFound),
1429    }
1430}
1431
1432// ---------- federation control API (scaffold; feature `federation`) ----------
1433
1434/// `GET /federation/state` — the instance's federated self-description.
1435#[cfg(feature = "federation")]
1436async fn federation_state(
1437    Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1438) -> Json<FederationState> {
1439    Json(cfg.to_state())
1440}
1441
1442/// `POST /federation/routed-write` — apply a write under an authoritative scope.
1443///
1444/// Scaffold: applies the write locally with provenance stamping (origin =
1445/// this instance, authority = the scope) but performs **no** boundary
1446/// enforcement — no trust-tier egress filtering, scope/airgap deny, or
1447/// boundary transformation. Ijima's non-bypassable safety floor is the
1448/// follow-on (ADR `federation-control-api` §Deferred).
1449#[cfg(feature = "federation")]
1450async fn routed_write(
1451    principal: AuthPrincipal,
1452    Extension(store): Extension<Arc<dyn Store>>,
1453    Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1454    Json(write): Json<RoutedWrite>,
1455) -> Result<Json<RoutedWriteReceipt>, ApiError> {
1456    if !principal.0.may(MEMORY_WRITE) {
1457        return Err(ApiError::Forbidden);
1458    }
1459    let RoutedWrite {
1460        target: _,
1461        scope,
1462        operation: _,
1463        payload,
1464    } = write;
1465
1466    // === Boundary enforcement (non-bypassable; the federation ingress path) ===
1467    // (1) Airgap: a sovereign instance rejects all federation writes.
1468    if cfg.role == ijima_core::federation::InstanceRole::Airgapped {
1469        return Err(ApiError::Forbidden);
1470    }
1471    // (2) Scope filter: accept only writes for scopes this instance is
1472    //     authoritative for (default-deny for sovereignty).
1473    if !cfg.accepts_scope(&scope) {
1474        return Err(ApiError::BadRequest(format!(
1475            "out of authoritative scope: {}/{}",
1476            scope.namespace, scope.project
1477        )));
1478    }
1479
1480    let mut memory: Memory = serde_json::from_value(payload)
1481        .map_err(|e| ApiError::BadRequest(format!("payload is not a Memory: {e}")))?;
1482    // Stamp federation provenance: this instance applied it; the routed scope
1483    // is the source-of-truth authority for the record.
1484    memory.origin = ijima_core::provenance::InstanceId::local();
1485    memory.authority =
1486        ijima_core::provenance::AuthorityScope(format!("{}/{}", scope.namespace, scope.project));
1487    if memory.created_at.is_empty() {
1488        memory.created_at = std::time::SystemTime::now()
1489            .duration_since(std::time::UNIX_EPOCH)
1490            .map(|d| d.as_secs().to_string())
1491            .unwrap_or_default();
1492    }
1493    let ns = principal.0.personal_namespace();
1494
1495    // (3) Trust-tier ingress: doctrine arriving via federation is never
1496    //     auto-trusted — stage it as PendingReview (never auto-promoted).
1497    //     Lower tiers (Explicit/Mined/AutoCapture) cross as-is.
1498    let (commit, mut warnings) = if memory.source == MemorySource::Doctrine {
1499        let pending = store
1500            .enqueue_extraction(&ns, memory, 0.5)
1501            .await
1502            .map_err(internal)?;
1503        (
1504            pending,
1505            vec!["doctrine downgraded to PendingReview (trust-tier ingress rule)".into()],
1506        )
1507    } else {
1508        let id = store.store_memory(&ns, memory).await.map_err(internal)?;
1509        (id.0, Vec::new())
1510    };
1511    warnings.push("boundary enforcement: scope + airgap + doctrine-downgrade applied".into());
1512    Ok(Json(RoutedWriteReceipt {
1513        accepted: true,
1514        instance: cfg.instance_id.clone(),
1515        scope,
1516        commit: Some(commit),
1517        warnings,
1518    }))
1519}
1520
1521/// `POST /federation/conflict-signal` — poll for a conflict on a scope.
1522///
1523/// Scaffold: no conflict detection yet. Returns `404` (no active conflict);
1524/// the single-instance deployment has no peer to conflict with.
1525#[cfg(feature = "federation")]
1526async fn conflict_signal(
1527    Json(_scope): Json<AuthoritativeScope>,
1528) -> Result<Json<ConflictSignal>, ApiError> {
1529    Err(ApiError::NotFound)
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534    use super::*;
1535    use crate::IjimaAuth;
1536    use axum::body::Body;
1537    use axum::http::{Request, StatusCode};
1538    use ijima_core::{harness::Harness, memory::MemorySource};
1539    use tower::ServiceExt;
1540
1541    async fn app_with_store() -> (Router, Arc<IjimaAuth>) {
1542        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1543        let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1544        let store: Arc<dyn Store> = store_inner.clone();
1545        let kg: Arc<dyn KnowledgeGraph> = store_inner;
1546        (
1547            app(
1548                auth.clone(),
1549                store,
1550                kg,
1551                None,
1552                Arc::new(crate::redaction::Redactor::new()),
1553                #[cfg(feature = "rate-limit")]
1554                None,
1555                #[cfg(feature = "federation")]
1556                Arc::new(InstanceFederationConfig::default()),
1557            ),
1558            auth,
1559        )
1560    }
1561
1562    /// Like [`app_with_store`] but with a custom federation config — for
1563    /// boundary-enforcement tests (airgap, out-of-scope).
1564    #[cfg(feature = "federation")]
1565    async fn app_with_federation_config(
1566        config: InstanceFederationConfig,
1567    ) -> (Router, Arc<IjimaAuth>) {
1568        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1569        let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1570        let store: Arc<dyn Store> = store_inner.clone();
1571        let kg: Arc<dyn KnowledgeGraph> = store_inner;
1572        (
1573            app(
1574                auth.clone(),
1575                store,
1576                kg,
1577                None,
1578                Arc::new(crate::redaction::Redactor::new()),
1579                #[cfg(feature = "rate-limit")]
1580                None,
1581                Arc::new(config),
1582            ),
1583            auth,
1584        )
1585    }
1586
1587    fn bearer(auth: &IjimaAuth, principal: &str, cap: &str) -> String {
1588        format!(
1589            "Bearer {}",
1590            auth.issue_bearer(principal, cap).expect("issue")
1591        )
1592    }
1593
1594    #[cfg(feature = "federation")]
1595    #[tokio::test]
1596    async fn federation_state_returns_local_config() {
1597        let (app, _auth) = app_with_store().await;
1598        let res = app
1599            .oneshot(
1600                Request::builder()
1601                    .uri("/federation/state")
1602                    .body(Body::empty())
1603                    .unwrap(),
1604            )
1605            .await
1606            .unwrap();
1607        assert_eq!(res.status(), StatusCode::OK);
1608        let state = body_json(res).await;
1609        assert_eq!(state["instance_id"], "local");
1610        assert_eq!(state["role"], "Unifying");
1611    }
1612
1613    #[cfg(feature = "federation")]
1614    #[tokio::test]
1615    async fn routed_write_applies_a_memory() {
1616        let (app, auth) = app_with_store().await;
1617        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1618        let body = serde_json::json!({
1619            "target": "local",
1620            "scope": {"namespace": "local", "project": "Dominic"},
1621            "operation": "Create",
1622            "payload": {
1623                "id": "mem_fed_test",
1624                "content": "federated hello",
1625                "project": "Dominic",
1626                "topic": "federated",
1627                "source": "Explicit",
1628                "harness": "Dominic"
1629            }
1630        })
1631        .to_string();
1632        let res = app
1633            .oneshot(
1634                Request::builder()
1635                    .method("POST")
1636                    .uri("/federation/routed-write")
1637                    .header("authorization", &write)
1638                    .header("content-type", "application/json")
1639                    .body(Body::from(body))
1640                    .unwrap(),
1641            )
1642            .await
1643            .unwrap();
1644        assert_eq!(res.status(), StatusCode::OK);
1645        let receipt = body_json(res).await;
1646        assert_eq!(receipt["accepted"], true);
1647        assert!(receipt["commit"].as_str().is_some());
1648        assert_eq!(
1649            receipt["warnings"][0],
1650            "boundary enforcement: scope + airgap + doctrine-downgrade applied"
1651        );
1652    }
1653
1654    #[cfg(feature = "federation")]
1655    #[tokio::test]
1656    async fn routed_write_requires_memory_write() {
1657        let (app, auth) = app_with_store().await;
1658        let read = bearer(&auth, "elliott", MEMORY_READ); // read cap, not write
1659        let body = serde_json::json!({
1660            "target": "local",
1661            "scope": {"namespace": "local", "project": "Dominic"},
1662            "operation": "Create",
1663            "payload": {
1664                "id": "x", "content": "c", "project": "p",
1665                "topic": "t", "source": "Explicit", "harness": "Dominic"
1666            }
1667        })
1668        .to_string();
1669        let res = app
1670            .oneshot(
1671                Request::builder()
1672                    .method("POST")
1673                    .uri("/federation/routed-write")
1674                    .header("authorization", &read)
1675                    .header("content-type", "application/json")
1676                    .body(Body::from(body))
1677                    .unwrap(),
1678            )
1679            .await
1680            .unwrap();
1681        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1682    }
1683
1684    #[cfg(feature = "federation")]
1685    #[tokio::test]
1686    async fn routed_write_rejects_out_of_scope() {
1687        let (app, auth) = app_with_store().await;
1688        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1689        // default config is authoritative for {local, *}; {shared, ...} is out of scope
1690        let body = serde_json::json!({
1691            "target": "local",
1692            "scope": {"namespace": "shared", "project": "Dominic"},
1693            "operation": "Create",
1694            "payload": {
1695                "id": "x", "content": "c", "project": "p",
1696                "topic": "t", "source": "Explicit", "harness": "Dominic"
1697            }
1698        })
1699        .to_string();
1700        let res = app
1701            .oneshot(
1702                Request::builder()
1703                    .method("POST")
1704                    .uri("/federation/routed-write")
1705                    .header("authorization", &write)
1706                    .header("content-type", "application/json")
1707                    .body(Body::from(body))
1708                    .unwrap(),
1709            )
1710            .await
1711            .unwrap();
1712        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
1713    }
1714
1715    #[cfg(feature = "federation")]
1716    #[tokio::test]
1717    async fn routed_write_rejects_when_airgapped() {
1718        let cfg = InstanceFederationConfig {
1719            role: ijima_core::federation::InstanceRole::Airgapped,
1720            ..InstanceFederationConfig::default()
1721        };
1722        let (app, auth) = app_with_federation_config(cfg).await;
1723        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1724        let body = serde_json::json!({
1725            "target": "local",
1726            "scope": {"namespace": "local", "project": "Dominic"},
1727            "operation": "Create",
1728            "payload": {
1729                "id": "x", "content": "c", "project": "p",
1730                "topic": "t", "source": "Explicit", "harness": "Dominic"
1731            }
1732        })
1733        .to_string();
1734        let res = app
1735            .oneshot(
1736                Request::builder()
1737                    .method("POST")
1738                    .uri("/federation/routed-write")
1739                    .header("authorization", &write)
1740                    .header("content-type", "application/json")
1741                    .body(Body::from(body))
1742                    .unwrap(),
1743            )
1744            .await
1745            .unwrap();
1746        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1747    }
1748
1749    #[cfg(feature = "federation")]
1750    #[tokio::test]
1751    async fn routed_write_downgrades_doctrine_to_pending() {
1752        let (app, auth) = app_with_store().await;
1753        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1754        let body = serde_json::json!({
1755            "target": "local",
1756            "scope": {"namespace": "local", "project": "Dominic"},
1757            "operation": "Create",
1758            "payload": {
1759                "id": "mem_doctrine",
1760                "content": "peer-claimed doctrine",
1761                "project": "Dominic",
1762                "topic": "federated",
1763                "source": "Doctrine",
1764                "harness": "Dominic"
1765            }
1766        })
1767        .to_string();
1768        let res = app
1769            .oneshot(
1770                Request::builder()
1771                    .method("POST")
1772                    .uri("/federation/routed-write")
1773                    .header("authorization", &write)
1774                    .header("content-type", "application/json")
1775                    .body(Body::from(body))
1776                    .unwrap(),
1777            )
1778            .await
1779            .unwrap();
1780        assert_eq!(res.status(), StatusCode::OK);
1781        let receipt = body_json(res).await;
1782        assert_eq!(receipt["accepted"], true);
1783        assert_eq!(
1784            receipt["warnings"][0],
1785            "doctrine downgraded to PendingReview (trust-tier ingress rule)"
1786        );
1787    }
1788
1789    #[cfg(feature = "federation")]
1790    #[tokio::test]
1791    async fn conflict_signal_returns_404_when_none() {
1792        let (app, _auth) = app_with_store().await;
1793        let body = serde_json::json!({"namespace": "shared", "project": "Dominic"}).to_string();
1794        let res = app
1795            .oneshot(
1796                Request::builder()
1797                    .method("POST")
1798                    .uri("/federation/conflict-signal")
1799                    .header("content-type", "application/json")
1800                    .body(Body::from(body))
1801                    .unwrap(),
1802            )
1803            .await
1804            .unwrap();
1805        assert_eq!(res.status(), StatusCode::NOT_FOUND);
1806    }
1807
1808    fn sample_memory_json(id: &str) -> String {
1809        serde_json::json!({
1810            "id": id,
1811            "content": "decided to wire the daemon",
1812            "project": "ijima",
1813            "topic": "api",
1814            "source": "Explicit",
1815            "harness": "Pi",
1816            "session_id": "sess_1",
1817            "importance": 0.5,
1818            "created_at": "0",
1819        })
1820        .to_string()
1821    }
1822
1823    #[tokio::test]
1824    async fn health_is_public() {
1825        let (app, _) = app_with_store().await;
1826        let res = app
1827            .oneshot(
1828                Request::builder()
1829                    .uri("/health")
1830                    .body(Body::empty())
1831                    .unwrap(),
1832            )
1833            .await
1834            .unwrap();
1835        assert_eq!(res.status(), StatusCode::OK);
1836    }
1837
1838    #[tokio::test]
1839    async fn recall_without_auth_is_401() {
1840        let (app, _) = app_with_store().await;
1841        let res = app
1842            .oneshot(
1843                Request::builder()
1844                    .uri("/memories/mem_1")
1845                    .body(Body::empty())
1846                    .unwrap(),
1847            )
1848            .await
1849            .unwrap();
1850        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
1851    }
1852
1853    #[tokio::test]
1854    async fn store_then_recall_round_trips() {
1855        let (app, auth) = app_with_store().await;
1856        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1857        let read = bearer(&auth, "elliott", MEMORY_READ);
1858
1859        // POST /memories
1860        let res = app
1861            .clone()
1862            .oneshot(
1863                Request::builder()
1864                    .method("POST")
1865                    .uri("/memories")
1866                    .header("authorization", &write)
1867                    .header("content-type", "application/json")
1868                    .body(Body::from(sample_memory_json("mem_1")))
1869                    .unwrap(),
1870            )
1871            .await
1872            .unwrap();
1873        assert_eq!(res.status(), StatusCode::OK);
1874
1875        // GET /memories/mem_1
1876        let res = app
1877            .oneshot(
1878                Request::builder()
1879                    .uri("/memories/mem_1")
1880                    .header("authorization", &read)
1881                    .body(Body::empty())
1882                    .unwrap(),
1883            )
1884            .await
1885            .unwrap();
1886        assert_eq!(res.status(), StatusCode::OK);
1887        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1888            .await
1889            .unwrap();
1890        let mem: Memory = serde_json::from_slice(&body).unwrap();
1891        assert_eq!(mem.content, "decided to wire the daemon");
1892        assert_eq!(mem.harness, Harness::Pi);
1893        assert_eq!(mem.source, MemorySource::Explicit);
1894    }
1895
1896    #[tokio::test]
1897    async fn store_with_read_only_token_is_403() {
1898        let (app, auth) = app_with_store().await;
1899        let read = bearer(&auth, "elliott", MEMORY_READ);
1900        let res = app
1901            .oneshot(
1902                Request::builder()
1903                    .method("POST")
1904                    .uri("/memories")
1905                    .header("authorization", &read)
1906                    .header("content-type", "application/json")
1907                    .body(Body::from(sample_memory_json("mem_x")))
1908                    .unwrap(),
1909            )
1910            .await
1911            .unwrap();
1912        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1913    }
1914
1915    #[tokio::test]
1916    async fn namespace_isolation_across_principals() {
1917        let (app, auth) = app_with_store().await;
1918        // alice stores
1919        let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1920        let _ = app
1921            .clone()
1922            .oneshot(
1923                Request::builder()
1924                    .method("POST")
1925                    .uri("/memories")
1926                    .header("authorization", &alice_write)
1927                    .header("content-type", "application/json")
1928                    .body(Body::from(sample_memory_json("mem_a")))
1929                    .unwrap(),
1930            )
1931            .await
1932            .unwrap();
1933        // bob cannot recall alice's memory
1934        let bob_read = bearer(&auth, "bob", MEMORY_READ);
1935        let res = app
1936            .oneshot(
1937                Request::builder()
1938                    .uri("/memories/mem_a")
1939                    .header("authorization", &bob_read)
1940                    .body(Body::empty())
1941                    .unwrap(),
1942            )
1943            .await
1944            .unwrap();
1945        assert_eq!(res.status(), StatusCode::NOT_FOUND);
1946    }
1947
1948    #[tokio::test]
1949    async fn promote_redacts_secrets_and_leaves_original_intact() {
1950        let (app, auth) = app_with_store().await;
1951        let write = bearer(&auth, "elliott", MEMORY_WRITE);
1952        let read = bearer(&auth, "elliott", MEMORY_READ);
1953        let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1954
1955        // WS3: elliott must be a member of the promotion target's org
1956        // wall — grant via the admin route (full-stack setup).
1957        let admin = bearer(&auth, "root", ADMIN);
1958        let res = app
1959            .clone()
1960            .oneshot(
1961                Request::builder()
1962                    .method("POST")
1963                    .uri("/namespaces/grant")
1964                    .header("authorization", &admin)
1965                    .header("content-type", "application/json")
1966                    .body(Body::from(
1967                        serde_json::json!({
1968                            "namespace": "ns_team_shared",
1969                            "principal": "elliott"
1970                        })
1971                        .to_string(),
1972                    ))
1973                    .unwrap(),
1974            )
1975            .await
1976            .unwrap();
1977        assert_eq!(res.status(), StatusCode::OK, "membership grant");
1978
1979        // Store a personal memory containing a secret.
1980        let body = serde_json::json!({
1981            "id": "mem_secret",
1982            "content": "deploy key sk-abcdefghijklmnopqrstuvwxyz1234567890 contact ops@test.com",
1983            "project": "ijima",
1984            "topic": "ops",
1985            "source": "Explicit",
1986            "harness": "Pi",
1987        })
1988        .to_string();
1989        let res = app
1990            .clone()
1991            .oneshot(
1992                Request::builder()
1993                    .method("POST")
1994                    .uri("/memories")
1995                    .header("authorization", &write)
1996                    .header("content-type", "application/json")
1997                    .body(Body::from(body))
1998                    .unwrap(),
1999            )
2000            .await
2001            .unwrap();
2002        assert_eq!(res.status(), StatusCode::OK);
2003
2004        // Promote to a shared namespace.
2005        let promote_body = serde_json::json!({
2006            "target_namespace": "ns_team_shared",
2007        })
2008        .to_string();
2009        let res = app
2010            .clone()
2011            .oneshot(
2012                Request::builder()
2013                    .method("POST")
2014                    .uri("/memories/mem_secret/promote")
2015                    .header("authorization", &promote)
2016                    .header("content-type", "application/json")
2017                    .body(Body::from(promote_body))
2018                    .unwrap(),
2019            )
2020            .await
2021            .unwrap();
2022        assert_eq!(res.status(), StatusCode::OK);
2023        let resp: serde_json::Value = serde_json::from_slice(
2024            &axum::body::to_bytes(res.into_body(), usize::MAX)
2025                .await
2026                .unwrap(),
2027        )
2028        .unwrap();
2029        let new_id = resp["id"].as_str().unwrap();
2030        assert_eq!(new_id, "mem_secret__shared");
2031        let cats: Vec<&str> = resp["redactions"]
2032            .as_array()
2033            .unwrap()
2034            .iter()
2035            .map(|r| r["category"].as_str().unwrap())
2036            .collect();
2037        assert!(cats.contains(&"api_key"));
2038        assert!(cats.contains(&"email"));
2039
2040        // The original personal memory is untouched (verbatim).
2041        let res = app
2042            .clone()
2043            .oneshot(
2044                Request::builder()
2045                    .uri("/memories/mem_secret")
2046                    .header("authorization", &read)
2047                    .body(Body::empty())
2048                    .unwrap(),
2049            )
2050            .await
2051            .unwrap();
2052        let orig: Memory = serde_json::from_slice(
2053            &axum::body::to_bytes(res.into_body(), usize::MAX)
2054                .await
2055                .unwrap(),
2056        )
2057        .unwrap();
2058        assert!(orig.content.contains("sk-abcdef"));
2059        assert!(orig.content.contains("ops@test.com"));
2060
2061        // The promoted shared copy is readable via ?namespace= and has
2062        // secrets scrubbed.
2063        let res = app
2064            .oneshot(
2065                Request::builder()
2066                    .uri("/memories/mem_secret__shared?namespace=ns_team_shared")
2067                    .header("authorization", &read)
2068                    .body(Body::empty())
2069                    .unwrap(),
2070            )
2071            .await
2072            .unwrap();
2073        assert_eq!(res.status(), StatusCode::OK);
2074        let shared: Memory = serde_json::from_slice(
2075            &axum::body::to_bytes(res.into_body(), usize::MAX)
2076                .await
2077                .unwrap(),
2078        )
2079        .unwrap();
2080        assert!(shared.content.contains("[REDACTED:api_key]"));
2081        assert!(shared.content.contains("[REDACTED:email]"));
2082        assert!(!shared.content.contains("sk-abcdef"));
2083        assert!(!shared.content.contains("ops@test.com"));
2084        // Provenance back-reference.
2085        assert_eq!(shared.session_id.as_deref(), Some("mem_secret"));
2086    }
2087
2088    #[tokio::test]
2089    async fn promote_requires_trust_promote_not_memory_write() {
2090        // ADR provenance-tier: raising trust is costlier than writing at a
2091        // tier, so promote_memory requires trust:promote (codim 4), not
2092        // memory:write (codim 2). A memory:write-only token gets 403.
2093        let (app, auth) = app_with_store().await;
2094        let write = bearer(&auth, "elliott", MEMORY_WRITE);
2095        let body = serde_json::json!({
2096            "id": "mem_p",
2097            "content": "provenance tier test",
2098            "project": "ijima",
2099            "topic": "t",
2100            "source": "Explicit",
2101            "harness": "Pi",
2102        })
2103        .to_string();
2104        // Store succeeds with memory:write.
2105        let res = app
2106            .clone()
2107            .oneshot(
2108                Request::builder()
2109                    .method("POST")
2110                    .uri("/memories")
2111                    .header("authorization", &write)
2112                    .header("content-type", "application/json")
2113                    .body(Body::from(body))
2114                    .unwrap(),
2115            )
2116            .await
2117            .unwrap();
2118        assert_eq!(res.status(), StatusCode::OK);
2119
2120        // Promote is forbidden with only memory:write.
2121        let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2122        let res = app
2123            .clone()
2124            .oneshot(
2125                Request::builder()
2126                    .method("POST")
2127                    .uri("/memories/mem_p/promote")
2128                    .header("authorization", &write)
2129                    .header("content-type", "application/json")
2130                    .body(Body::from(promote_body))
2131                    .unwrap(),
2132            )
2133            .await
2134            .unwrap();
2135        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2136
2137        // WS3: the promotion target is membership-gated — grant elliott
2138        // into ns_team_shared via the admin route, then the trust:promote
2139        // holder succeeds.
2140        let admin = bearer(&auth, "root", ADMIN);
2141        let res = app
2142            .clone()
2143            .oneshot(
2144                Request::builder()
2145                    .method("POST")
2146                    .uri("/namespaces/grant")
2147                    .header("authorization", &admin)
2148                    .header("content-type", "application/json")
2149                    .body(Body::from(
2150                        serde_json::json!({
2151                            "namespace": "ns_team_shared",
2152                            "principal": "elliott"
2153                        })
2154                        .to_string(),
2155                    ))
2156                    .unwrap(),
2157            )
2158            .await
2159            .unwrap();
2160        assert_eq!(res.status(), StatusCode::OK, "membership grant");
2161
2162        let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
2163        let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2164        let res = app
2165            .oneshot(
2166                Request::builder()
2167                    .method("POST")
2168                    .uri("/memories/mem_p/promote")
2169                    .header("authorization", &promote)
2170                    .header("content-type", "application/json")
2171                    .body(Body::from(promote_body))
2172                    .unwrap(),
2173            )
2174            .await
2175            .unwrap();
2176        assert_eq!(res.status(), StatusCode::OK);
2177    }
2178
2179    // ---------- WS3 org walls ----------
2180
2181    /// The full wall lifecycle: non-member 403 → admin grants → member
2182    /// 200 → revoke → 403 again. Also pins the admin bypass.
2183    #[tokio::test]
2184    async fn shared_namespace_membership_lifecycle() {
2185        let (app, auth) = app_with_store().await;
2186        let rw = bearer(&auth, "elliott", MEMORY_WRITE);
2187        let admin = bearer(&auth, "root", ADMIN);
2188
2189        let write_into = |app: Router, token: String, n: u8| async move {
2190            app.oneshot(
2191                Request::builder()
2192                    .method("POST")
2193                    .uri("/memories?namespace=ns_ia_shared")
2194                    .header("authorization", token)
2195                    .header("content-type", "application/json")
2196                    .body(Body::from(
2197                        serde_json::json!({
2198                            "id": format!("mem_wall_{n}"),
2199                            "content": format!("org-wall probe {n}"),
2200                            "project": "ijima",
2201                            "topic": "ws3",
2202                            "source": "Explicit",
2203                            "harness": "Pi",
2204                            "importance": 0.5,
2205                            "created_at": "0",
2206                        })
2207                        .to_string(),
2208                    ))
2209                    .unwrap(),
2210            )
2211            .await
2212            .unwrap()
2213        };
2214
2215        // 1. Non-member is walled out.
2216        let res = write_into(app.clone(), rw.clone(), 1).await;
2217        assert_eq!(
2218            res.status(),
2219            StatusCode::FORBIDDEN,
2220            "non-member must be walled"
2221        );
2222
2223        // 2. Admin bypasses without membership.
2224        let res = write_into(app.clone(), admin.clone(), 2).await;
2225        assert_eq!(res.status(), StatusCode::OK, "admin bypass");
2226
2227        // 3. Non-admin cannot grant.
2228        let res = app
2229            .clone()
2230            .oneshot(
2231                Request::builder()
2232                    .method("POST")
2233                    .uri("/namespaces/grant")
2234                    .header("authorization", rw.clone())
2235                    .header("content-type", "application/json")
2236                    .body(Body::from(
2237                        serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2238                            .to_string(),
2239                    ))
2240                    .unwrap(),
2241            )
2242            .await
2243            .unwrap();
2244        assert_eq!(res.status(), StatusCode::FORBIDDEN, "grant requires admin");
2245
2246        // 4. Admin grants → member writes fine.
2247        let res = app
2248            .clone()
2249            .oneshot(
2250                Request::builder()
2251                    .method("POST")
2252                    .uri("/namespaces/grant")
2253                    .header("authorization", admin.clone())
2254                    .header("content-type", "application/json")
2255                    .body(Body::from(
2256                        serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2257                            .to_string(),
2258                    ))
2259                    .unwrap(),
2260            )
2261            .await
2262            .unwrap();
2263        assert_eq!(res.status(), StatusCode::OK);
2264        let res = write_into(app.clone(), rw.clone(), 3).await;
2265        assert_eq!(res.status(), StatusCode::OK, "member passes");
2266
2267        // 5. Members listing (admin) shows the grant.
2268        let res = app
2269            .clone()
2270            .oneshot(
2271                Request::builder()
2272                    .uri("/namespaces/members?namespace=ns_ia_shared")
2273                    .header("authorization", admin.clone())
2274                    .body(Body::empty())
2275                    .unwrap(),
2276            )
2277            .await
2278            .unwrap();
2279        assert_eq!(res.status(), StatusCode::OK);
2280        let members = body_json(res).await;
2281        assert_eq!(members[0]["principal"].as_str(), Some("elliott"));
2282        assert_eq!(members[0]["granted_by"].as_str(), Some("root"));
2283
2284        // 6. Revoke → walled again.
2285        let res = app
2286            .clone()
2287            .oneshot(
2288                Request::builder()
2289                    .method("POST")
2290                    .uri("/namespaces/revoke")
2291                    .header("authorization", admin.clone())
2292                    .header("content-type", "application/json")
2293                    .body(Body::from(
2294                        serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2295                            .to_string(),
2296                    ))
2297                    .unwrap(),
2298            )
2299            .await
2300            .unwrap();
2301        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2302        let res = write_into(app, rw, 4).await;
2303        assert_eq!(
2304            res.status(),
2305            StatusCode::FORBIDDEN,
2306            "revoked member is walled"
2307        );
2308    }
2309
2310    /// Open namespaces stay open: doctrine and import staging need no
2311    /// membership.
2312    #[tokio::test]
2313    async fn doctrine_and_import_namespaces_stay_open() {
2314        let (app, auth) = app_with_store().await;
2315        let read = bearer(&auth, "elliott", MEMORY_READ);
2316
2317        let res = app
2318            .clone()
2319            .oneshot(
2320                Request::builder()
2321                    .uri("/memories?namespace=ns_doctrine&limit=5")
2322                    .header("authorization", read.clone())
2323                    .body(Body::empty())
2324                    .unwrap(),
2325            )
2326            .await
2327            .unwrap();
2328        assert_eq!(res.status(), StatusCode::OK, "doctrine is readable by all");
2329
2330        let res = app
2331            .clone()
2332            .oneshot(
2333                Request::builder()
2334                    .uri("/memories?namespace=ns_import_probe&limit=5")
2335                    .header("authorization", read)
2336                    .body(Body::empty())
2337                    .unwrap(),
2338            )
2339            .await
2340            .unwrap();
2341        assert_eq!(res.status(), StatusCode::OK, "import staging is open");
2342    }
2343
2344    #[tokio::test]
2345    async fn cross_principal_personal_namespace_is_forbidden() {
2346        let (app, auth) = app_with_store().await;
2347        // Alice stores a memory.
2348        let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
2349        let _ = app
2350            .clone()
2351            .oneshot(
2352                Request::builder()
2353                    .method("POST")
2354                    .uri("/memories")
2355                    .header("authorization", &alice_write)
2356                    .header("content-type", "application/json")
2357                    .body(Body::from(
2358                        serde_json::json!({
2359                            "id": "mem_a",
2360                            "content": "alice only",
2361                            "project": "x",
2362                            "topic": "x",
2363                            "source": "Explicit",
2364                            "harness": "Pi",
2365                        })
2366                        .to_string(),
2367                    ))
2368                    .unwrap(),
2369            )
2370            .await
2371            .unwrap();
2372
2373        // Bob tries to read alice's personal namespace explicitly.
2374        let bob_read = bearer(&auth, "bob", MEMORY_READ);
2375        let res = app
2376            .oneshot(
2377                Request::builder()
2378                    .uri("/memories/mem_a?namespace=ns_alice_private")
2379                    .header("authorization", &bob_read)
2380                    .body(Body::empty())
2381                    .unwrap(),
2382            )
2383            .await
2384            .unwrap();
2385        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2386    }
2387
2388    #[tokio::test]
2389    async fn doctrine_ingest_requires_admin_and_is_readable_shared() {
2390        let (app, auth) = app_with_store().await;
2391        let admin = bearer(&auth, "ci", "admin");
2392        let read = bearer(&auth, "anyone", MEMORY_READ);
2393
2394        // Non-admin cannot ingest doctrine.
2395        let res = app
2396            .clone()
2397            .oneshot(
2398                Request::builder()
2399                    .method("POST")
2400                    .uri("/doctrine")
2401                    .header("authorization", &read)
2402                    .header("content-type", "application/json")
2403                    .body(Body::from(
2404                        serde_json::json!({
2405                            "id": "d1",
2406                            "content": "doctrine body",
2407                            "project": "ijima",
2408                            "topic": "arch",
2409                        })
2410                        .to_string(),
2411                    ))
2412                    .unwrap(),
2413            )
2414            .await
2415            .unwrap();
2416        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2417
2418        // Admin ingests.
2419        let res = app
2420            .clone()
2421            .oneshot(
2422                Request::builder()
2423                    .method("POST")
2424                    .uri("/doctrine")
2425                    .header("authorization", &admin)
2426                    .header("content-type", "application/json")
2427                    .body(Body::from(
2428                        serde_json::json!({
2429                            "id": "d1",
2430                            "content": "doctrine body",
2431                            "project": "ijima",
2432                            "topic": "arch",
2433                        })
2434                        .to_string(),
2435                    ))
2436                    .unwrap(),
2437            )
2438            .await
2439            .unwrap();
2440        assert_eq!(res.status(), StatusCode::OK);
2441
2442        // Any read-capable principal can recall doctrine from ns_doctrine.
2443        let res = app
2444            .oneshot(
2445                Request::builder()
2446                    .uri("/memories/d1?namespace=ns_doctrine")
2447                    .header("authorization", &read)
2448                    .body(Body::empty())
2449                    .unwrap(),
2450            )
2451            .await
2452            .unwrap();
2453        assert_eq!(res.status(), StatusCode::OK);
2454        let mem: Memory = serde_json::from_slice(
2455            &axum::body::to_bytes(res.into_body(), usize::MAX)
2456                .await
2457                .unwrap(),
2458        )
2459        .unwrap();
2460        assert_eq!(mem.content, "doctrine body");
2461        assert_eq!(mem.source, ijima_core::memory::MemorySource::Doctrine);
2462    }
2463
2464    #[tokio::test]
2465    async fn wakeup_composes_personal_and_doctrine() {
2466        let (app, auth) = app_with_store().await;
2467        let write = bearer(&auth, "elliott", MEMORY_WRITE);
2468        let admin = bearer(&auth, "ci", "admin");
2469        let read = bearer(&auth, "elliott", MEMORY_READ);
2470
2471        // Store a personal memory.
2472        let _ = app
2473            .clone()
2474            .oneshot(
2475                Request::builder()
2476                    .method("POST")
2477                    .uri("/memories")
2478                    .header("authorization", &write)
2479                    .header("content-type", "application/json")
2480                    .body(Body::from(
2481                        serde_json::json!({
2482                            "id": "mem_p",
2483                            "content": "personal essential",
2484                            "project": "ijima",
2485                            "topic": "x",
2486                            "source": "Explicit",
2487                            "harness": "Pi",
2488                        })
2489                        .to_string(),
2490                    ))
2491                    .unwrap(),
2492            )
2493            .await
2494            .unwrap();
2495
2496        // Ingest doctrine.
2497        let _ = app
2498            .clone()
2499            .oneshot(
2500                Request::builder()
2501                    .method("POST")
2502                    .uri("/doctrine")
2503                    .header("authorization", &admin)
2504                    .header("content-type", "application/json")
2505                    .body(Body::from(
2506                        serde_json::json!({
2507                            "id": "doc_1",
2508                            "content": "doctrine baseline",
2509                            "project": "ijima",
2510                            "topic": "arch",
2511                        })
2512                        .to_string(),
2513                    ))
2514                    .unwrap(),
2515            )
2516            .await
2517            .unwrap();
2518
2519        // Wake-up composes both.
2520        let res = app
2521            .oneshot(
2522                Request::builder()
2523                    .uri("/wakeup")
2524                    .header("authorization", &read)
2525                    .body(Body::empty())
2526                    .unwrap(),
2527            )
2528            .await
2529            .unwrap();
2530        assert_eq!(res.status(), StatusCode::OK);
2531        let body: serde_json::Value = serde_json::from_slice(
2532            &axum::body::to_bytes(res.into_body(), usize::MAX)
2533                .await
2534                .unwrap(),
2535        )
2536        .unwrap();
2537        assert_eq!(body["identity"]["principal"], "elliott");
2538        assert_eq!(body["personal_essentials"].as_array().unwrap().len(), 1);
2539        assert_eq!(
2540            body["personal_essentials"][0]["content"],
2541            "personal essential"
2542        );
2543        assert_eq!(body["doctrine"].as_array().unwrap().len(), 1);
2544        assert_eq!(body["doctrine"][0]["content"], "doctrine baseline");
2545        assert_eq!(body["doctrine"][0]["source"], "Doctrine");
2546    }
2547
2548    #[cfg(feature = "rate-limit")]
2549    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2550    async fn import_memories_backs_off_through_rate_limit() {
2551        // Regression (production, 2026-08-21): the first 14k-row import lost
2552        // 13.6k memories because 429s were counted as skips. The client now
2553        // retries with backoff, so the same import completes — slowly —
2554        // through a tiny rate bucket. Real socket, real client.
2555        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
2556        let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
2557        let store: Arc<dyn Store> = store_inner.clone();
2558        let kg: Arc<dyn KnowledgeGraph> = store_inner;
2559        let app = app(
2560            auth.clone(),
2561            store,
2562            kg,
2563            None,
2564            Arc::new(crate::redaction::Redactor::new()),
2565            Some(crate::rate_limit::make_rate_limiter(1.0, 1.0)),
2566            #[cfg(feature = "federation")]
2567            Arc::new(InstanceFederationConfig::default()),
2568        );
2569        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2570        let addr = listener.local_addr().unwrap();
2571        tokio::spawn(async move {
2572            axum::serve(listener, app).await.unwrap();
2573        });
2574
2575        // One multi-capability grant: check (read) + store (write).
2576        let token = auth
2577            .issue_grant_bearer("elliott", &[MEMORY_READ, MEMORY_WRITE])
2578            .expect("issue");
2579        let client = ijima_client::Client::new(
2580            ijima_client::ClientConfig::new(format!("http://{addr}"), Harness::Pi)
2581                .with_token(token),
2582        );
2583        let ns = format!("ns_import_ratelimit_{}", std::process::id());
2584        let memories: Vec<Memory> = (0..5)
2585            .map(|i| Memory {
2586                id: MemoryId(format!("mem_backoff_{i}")),
2587                content: format!("backoff corpus row {i} for rate-limit regression"),
2588                project: "ijima".into(),
2589                topic: "test".into(),
2590                source: ijima_core::memory::MemorySource::AutoCapture,
2591                harness: Harness::Pi,
2592                session_id: None,
2593                origin: ijima_core::InstanceId::local(),
2594                authority: ijima_core::AuthorityScope::local(),
2595                importance: 0.5,
2596                created_at: "0".into(),
2597            })
2598            .collect();
2599        let counts = client.import_memories(&ns, memories).await.expect("import");
2600        assert_eq!(counts.attempted, 5);
2601        assert_eq!(counts.added, 5, "no memory may be lost to 429s");
2602        assert_eq!(counts.skipped, 0);
2603    }
2604
2605    #[tokio::test]
2606    async fn knowledge_graph_add_honors_namespace_param() {
2607        let (app, auth) = app_with_store().await;
2608        let write = bearer(&auth, "elliott", "knowledge:write");
2609        let read = bearer(&auth, "elliott", "knowledge:read");
2610
2611        // Add a triple into the open staging namespace via ?namespace=.
2612        let res = app
2613            .clone()
2614            .oneshot(
2615                Request::builder()
2616                    .method("POST")
2617                    .uri("/kg/triples?namespace=ns_import_stage")
2618                    .header("authorization", &write)
2619                    .header("content-type", "application/json")
2620                    .body(Body::from(
2621                        serde_json::json!({
2622                            "subject": "Ijima",
2623                            "predicate": "depends_on",
2624                            "object": "Schubert",
2625                        })
2626                        .to_string(),
2627                    ))
2628                    .unwrap(),
2629            )
2630            .await
2631            .unwrap();
2632        assert_eq!(res.status(), StatusCode::OK);
2633
2634        // Default (personal) namespace stays empty …
2635        let res = app
2636            .clone()
2637            .oneshot(
2638                Request::builder()
2639                    .uri("/kg/stats")
2640                    .header("authorization", &read)
2641                    .body(Body::empty())
2642                    .unwrap(),
2643            )
2644            .await
2645            .unwrap();
2646        let personal: serde_json::Value = serde_json::from_slice(
2647            &axum::body::to_bytes(res.into_body(), usize::MAX)
2648                .await
2649                .unwrap(),
2650        )
2651        .unwrap();
2652        assert_eq!(personal["triples"], 0);
2653
2654        // … and the staging namespace reports the edge.
2655        let res = app
2656            .clone()
2657            .oneshot(
2658                Request::builder()
2659                    .uri("/kg/stats?namespace=ns_import_stage")
2660                    .header("authorization", &read)
2661                    .body(Body::empty())
2662                    .unwrap(),
2663            )
2664            .await
2665            .unwrap();
2666        let staged: serde_json::Value = serde_json::from_slice(
2667            &axum::body::to_bytes(res.into_body(), usize::MAX)
2668                .await
2669                .unwrap(),
2670        )
2671        .unwrap();
2672        assert_eq!(staged["triples"], 1);
2673    }
2674
2675    #[tokio::test]
2676    async fn knowledge_graph_add_query_invalidate() {
2677        let (app, auth) = app_with_store().await;
2678        let write = bearer(&auth, "elliott", "knowledge:write");
2679        let read = bearer(&auth, "elliott", "knowledge:read");
2680
2681        // Add a triple.
2682        let res = app
2683            .clone()
2684            .oneshot(
2685                Request::builder()
2686                    .method("POST")
2687                    .uri("/kg/triples")
2688                    .header("authorization", &write)
2689                    .header("content-type", "application/json")
2690                    .body(Body::from(
2691                        serde_json::json!({
2692                            "subject": "Ijima",
2693                            "predicate": "depends_on",
2694                            "object": "SurrealDB",
2695                            "confidence": 1.0,
2696                        })
2697                        .to_string(),
2698                    ))
2699                    .unwrap(),
2700            )
2701            .await
2702            .unwrap();
2703        assert_eq!(res.status(), StatusCode::OK);
2704
2705        // Query the entity — outgoing edge present.
2706        let res = app
2707            .clone()
2708            .oneshot(
2709                Request::builder()
2710                    .uri("/kg/entities/Ijima")
2711                    .header("authorization", &read)
2712                    .body(Body::empty())
2713                    .unwrap(),
2714            )
2715            .await
2716            .unwrap();
2717        assert_eq!(res.status(), StatusCode::OK);
2718        let body: serde_json::Value = serde_json::from_slice(
2719            &axum::body::to_bytes(res.into_body(), usize::MAX)
2720                .await
2721                .unwrap(),
2722        )
2723        .unwrap();
2724        assert_eq!(body["outgoing"].as_array().unwrap().len(), 1);
2725        assert_eq!(body["outgoing"][0]["object"], "SurrealDB");
2726        assert!(body["incoming"].as_array().unwrap().is_empty());
2727
2728        // Stats.
2729        let res = app
2730            .clone()
2731            .oneshot(
2732                Request::builder()
2733                    .uri("/kg/stats")
2734                    .header("authorization", &read)
2735                    .body(Body::empty())
2736                    .unwrap(),
2737            )
2738            .await
2739            .unwrap();
2740        let body: serde_json::Value = serde_json::from_slice(
2741            &axum::body::to_bytes(res.into_body(), usize::MAX)
2742                .await
2743                .unwrap(),
2744        )
2745        .unwrap();
2746        assert_eq!(body["entities"], 2);
2747        assert_eq!(body["triples"], 1);
2748
2749        // Invalidate.
2750        let res = app
2751            .oneshot(
2752                Request::builder()
2753                    .method("POST")
2754                    .uri("/kg/triples/Ijima:depends_on:SurrealDB/invalidate")
2755                    .header("authorization", &write)
2756                    .body(Body::empty())
2757                    .unwrap(),
2758            )
2759            .await
2760            .unwrap();
2761        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2762    }
2763
2764    #[tokio::test]
2765    async fn status_requires_admin_and_reports_counts() {
2766        let (app, auth) = app_with_store().await;
2767        let admin = bearer(&auth, "op", "admin");
2768        let read = bearer(&auth, "user", MEMORY_READ);
2769
2770        // Store a memory + a triple so counts are non-zero.
2771        let _ = app
2772            .clone()
2773            .oneshot(
2774                Request::builder()
2775                    .method("POST")
2776                    .uri("/memories")
2777                    .header("authorization", &admin)
2778                    .header("content-type", "application/json")
2779                    .body(Body::from(
2780                        serde_json::json!({
2781                            "id": "m1",
2782                            "content": "stat test",
2783                            "project": "x",
2784                            "topic": "x",
2785                            "source": "Explicit",
2786                            "harness": "Pi",
2787                        })
2788                        .to_string(),
2789                    ))
2790                    .unwrap(),
2791            )
2792            .await
2793            .unwrap();
2794
2795        // Non-admin is forbidden.
2796        let res = app
2797            .clone()
2798            .oneshot(
2799                Request::builder()
2800                    .uri("/status")
2801                    .header("authorization", &read)
2802                    .body(Body::empty())
2803                    .unwrap(),
2804            )
2805            .await
2806            .unwrap();
2807        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2808
2809        // Admin sees global counts.
2810        let res = app
2811            .oneshot(
2812                Request::builder()
2813                    .uri("/status")
2814                    .header("authorization", &admin)
2815                    .body(Body::empty())
2816                    .unwrap(),
2817            )
2818            .await
2819            .unwrap();
2820        assert_eq!(res.status(), StatusCode::OK);
2821        let body: serde_json::Value = serde_json::from_slice(
2822            &axum::body::to_bytes(res.into_body(), usize::MAX)
2823                .await
2824                .unwrap(),
2825        )
2826        .unwrap();
2827        assert_eq!(body["memories"], 1);
2828        // Deploy-kit fields: version pinned to the crate version, sane
2829        // uptime, real start time.
2830        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2831        let uptime = body["uptime_secs"].as_u64().expect("uptime is u64");
2832        assert!(uptime < 60, "fresh test app should have tiny uptime");
2833        assert!(
2834            body["started_at_unix"].as_u64().expect("started_at is u64") > 1_000_000_000,
2835            "started_at looks like a unix timestamp"
2836        );
2837        assert!(!body["namespaces"].as_array().unwrap().is_empty());
2838    }
2839
2840    #[tokio::test]
2841    async fn sessions_create_list_end_via_http() {
2842        let (app, auth) = app_with_store().await;
2843        let ingest = bearer(&auth, "op", SESSION_INGEST);
2844        let read = bearer(&auth, "op", MEMORY_READ);
2845
2846        // Create two sessions.
2847        for (id, harness) in [("sess_a", "Pi"), ("sess_b", "Sakamoto")] {
2848            let res = app
2849                .clone()
2850                .oneshot(
2851                    Request::builder()
2852                        .method("POST")
2853                        .uri("/sessions")
2854                        .header("authorization", &ingest)
2855                        .header("content-type", "application/json")
2856                        .body(Body::from(
2857                            serde_json::json!({
2858                                "id": id,
2859                                "harness": harness,
2860                                "channel": "thread-1",
2861                                "started_at": "2026-07-05T10:00:00Z",
2862                            })
2863                            .to_string(),
2864                        ))
2865                        .unwrap(),
2866                )
2867                .await
2868                .unwrap();
2869            assert_eq!(res.status(), StatusCode::OK);
2870        }
2871
2872        // List — both present.
2873        let res = app
2874            .clone()
2875            .oneshot(
2876                Request::builder()
2877                    .uri("/sessions")
2878                    .header("authorization", &read)
2879                    .body(Body::empty())
2880                    .unwrap(),
2881            )
2882            .await
2883            .unwrap();
2884        assert_eq!(res.status(), StatusCode::OK);
2885        let body: serde_json::Value = serde_json::from_slice(
2886            &axum::body::to_bytes(res.into_body(), usize::MAX)
2887                .await
2888                .unwrap(),
2889        )
2890        .unwrap();
2891        let arr = body.as_array().unwrap();
2892        assert_eq!(arr.len(), 2);
2893
2894        // Filter by harness=pi.
2895        let res = app
2896            .clone()
2897            .oneshot(
2898                Request::builder()
2899                    .uri("/sessions?harness=pi")
2900                    .header("authorization", &read)
2901                    .body(Body::empty())
2902                    .unwrap(),
2903            )
2904            .await
2905            .unwrap();
2906        let body: serde_json::Value = serde_json::from_slice(
2907            &axum::body::to_bytes(res.into_body(), usize::MAX)
2908                .await
2909                .unwrap(),
2910        )
2911        .unwrap();
2912        assert_eq!(body.as_array().unwrap().len(), 1);
2913        assert_eq!(body[0]["harness"], "Pi");
2914
2915        // End sess_a.
2916        let res = app
2917            .clone()
2918            .oneshot(
2919                Request::builder()
2920                    .method("POST")
2921                    .uri("/sessions/sess_a/end")
2922                    .header("authorization", &ingest)
2923                    .header("content-type", "application/json")
2924                    .body(Body::from(
2925                        serde_json::json!({ "ended_at": "2026-07-05T11:00:00Z" }).to_string(),
2926                    ))
2927                    .unwrap(),
2928            )
2929            .await
2930            .unwrap();
2931        assert_eq!(res.status(), StatusCode::NO_CONTENT);
2932
2933        // Verify ended_at is persisted.
2934        let res = app
2935            .oneshot(
2936                Request::builder()
2937                    .uri("/sessions?harness=pi")
2938                    .header("authorization", &read)
2939                    .body(Body::empty())
2940                    .unwrap(),
2941            )
2942            .await
2943            .unwrap();
2944        let body: serde_json::Value = serde_json::from_slice(
2945            &axum::body::to_bytes(res.into_body(), usize::MAX)
2946                .await
2947                .unwrap(),
2948        )
2949        .unwrap();
2950        assert_eq!(body[0]["ended_at"], "2026-07-05T11:00:00Z");
2951    }
2952
2953    #[tokio::test]
2954    async fn mining_queue_requires_review_capability() {
2955        let (app, auth) = app_with_store().await;
2956        let reviewer = bearer(&auth, "op", MINING_REVIEW);
2957        let reader = bearer(&auth, "op", MEMORY_READ);
2958
2959        // A memory:read holder cannot list the queue.
2960        let res = app
2961            .clone()
2962            .oneshot(
2963                Request::builder()
2964                    .uri("/mining/queue")
2965                    .header("authorization", &reader)
2966                    .body(Body::empty())
2967                    .unwrap(),
2968            )
2969            .await
2970            .unwrap();
2971        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2972
2973        // A mining:review holder can list (empty queue).
2974        let res = app
2975            .oneshot(
2976                Request::builder()
2977                    .uri("/mining/queue")
2978                    .header("authorization", &reviewer)
2979                    .body(Body::empty())
2980                    .unwrap(),
2981            )
2982            .await
2983            .unwrap();
2984        assert_eq!(res.status(), StatusCode::OK);
2985        let body: serde_json::Value = serde_json::from_slice(
2986            &axum::body::to_bytes(res.into_body(), usize::MAX)
2987                .await
2988                .unwrap(),
2989        )
2990        .unwrap();
2991        assert!(body.as_array().unwrap().is_empty());
2992    }
2993
2994    fn hit_mem(id: &str, sim: f32) -> SearchHit {
2995        SearchHit {
2996            memory: Memory {
2997                id: MemoryId(id.into()),
2998                content: id.into(),
2999                project: "p".into(),
3000                topic: "t".into(),
3001                source: ijima_core::MemorySource::Explicit,
3002                harness: ijima_core::harness::Harness::Pi,
3003                session_id: None,
3004                origin: ijima_core::InstanceId::local(),
3005                authority: ijima_core::AuthorityScope::local(),
3006                importance: 0.5,
3007                created_at: "0".into(),
3008            },
3009            similarity: sim,
3010        }
3011    }
3012
3013    #[test]
3014    fn merge_search_hits_ranks_desc_dedups_and_truncates() {
3015        // scope=visible merge: two ranked lists combine by similarity, dedup
3016        // by memory id (first wins), truncate to limit.
3017        let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.5)];
3018        let b = vec![hit_mem("c", 0.8), hit_mem("a", 0.7)]; // 'a' dup, lower sim
3019        let merged = merge_search_hits(a, b, 3);
3020        // Sorted by similarity desc: a(0.9), c(0.8), b(0.5) — the dup a(0.7)
3021        // is dropped (first wins).
3022        assert_eq!(merged.len(), 3);
3023        assert_eq!(merged[0].memory.id.0, "a");
3024        assert_eq!((merged[0].similarity * 10.0).round() as i32, 9);
3025        assert_eq!(merged[1].memory.id.0, "c");
3026        assert_eq!(merged[2].memory.id.0, "b");
3027    }
3028
3029    #[test]
3030    fn merge_search_hits_respects_limit() {
3031        let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.8)];
3032        let b = vec![hit_mem("c", 0.7), hit_mem("d", 0.6)];
3033        let merged = merge_search_hits(a, b, 2);
3034        assert_eq!(merged.len(), 2);
3035        assert_eq!(merged[0].memory.id.0, "a");
3036        assert_eq!(merged[1].memory.id.0, "b");
3037    }
3038
3039    #[cfg(feature = "mining")]
3040    #[tokio::test]
3041    async fn trigger_requires_mining_trigger_capability() {
3042        let (app, auth) = app_with_store().await;
3043        // A memory:write holder cannot trigger mining.
3044        let write = bearer(&auth, "elliott", MEMORY_WRITE);
3045        let res = app
3046            .oneshot(
3047                Request::builder()
3048                    .method("POST")
3049                    .uri("/sessions/sess_x/mine")
3050                    .header("authorization", &write)
3051                    .body(Body::empty())
3052                    .unwrap(),
3053            )
3054            .await
3055            .unwrap();
3056        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3057    }
3058
3059    #[cfg(feature = "mining")]
3060    #[tokio::test]
3061    async fn trigger_mines_decision_and_archives() {
3062        // Rules-only: assumes no IJIMA_LLM_* env is set (CI is clean). When
3063        // env is unset, `build_mining_agent` returns None and `mine_all` runs
3064        // the deterministic rules tier.
3065        let (app, auth) = app_with_store().await;
3066        let ingest = bearer(&auth, "elliott", SESSION_INGEST);
3067        let trigger = bearer(&auth, "elliott", MINING_TRIGGER);
3068
3069        // Ingest a decision-bearing turn into elliott's personal namespace.
3070        let turn = serde_json::json!({
3071            "session_id": "sess_mine",
3072            "turn_index": 0,
3073            "role": "User",
3074            "content": "We decided to use SurrealDB for storage.",
3075            "timestamp": "0",
3076        });
3077        let res = app
3078            .clone()
3079            .oneshot(
3080                Request::builder()
3081                    .method("POST")
3082                    .uri("/sessions/sess_mine/turns")
3083                    .header("authorization", &ingest)
3084                    .header("content-type", "application/json")
3085                    .body(Body::from(turn.to_string()))
3086                    .unwrap(),
3087            )
3088            .await
3089            .unwrap();
3090        assert_eq!(res.status(), StatusCode::NO_CONTENT);
3091
3092        // Trigger mining (rules-only: no IJIMA_LLM_* env in tests).
3093        let res = app
3094            .oneshot(
3095                Request::builder()
3096                    .method("POST")
3097                    .uri("/sessions/sess_mine/mine")
3098                    .header("authorization", &trigger)
3099                    .body(Body::empty())
3100                    .unwrap(),
3101            )
3102            .await
3103            .unwrap();
3104        assert_eq!(res.status(), StatusCode::OK);
3105        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
3106            .await
3107            .unwrap();
3108        let report: crate::mining_pipeline::MiningReport = serde_json::from_slice(&body).unwrap();
3109        assert!(
3110            report.archived >= 1,
3111            "rules tier should archive the decision: {report:?}"
3112        );
3113    }
3114
3115    // ===== Palace / diary / repo route tests (Phase B) =====
3116
3117    async fn body_json(res: axum::response::Response) -> serde_json::Value {
3118        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
3119            .await
3120            .unwrap();
3121        serde_json::from_slice(&body).unwrap()
3122    }
3123
3124    async fn seed_memory(app: &Router, auth: &IjimaAuth, id: &str, project: &str, topic: &str) {
3125        let body = serde_json::json!({
3126            "id": id,
3127            "content": format!("{project}/{topic} note"),
3128            "project": project,
3129            "topic": topic,
3130            "source": "Explicit",
3131            "harness": "Pi",
3132            "session_id": "sess_1",
3133            "importance": 0.5,
3134            "created_at": "0",
3135        })
3136        .to_string();
3137        let res = app
3138            .clone()
3139            .oneshot(
3140                Request::builder()
3141                    .method("POST")
3142                    .uri("/memories")
3143                    .header("authorization", bearer(auth, "elliott", MEMORY_WRITE))
3144                    .header("content-type", "application/json")
3145                    .body(Body::from(body))
3146                    .unwrap(),
3147            )
3148            .await
3149            .unwrap();
3150        assert_eq!(res.status(), StatusCode::OK, "seed {id} failed");
3151    }
3152
3153    #[tokio::test]
3154    async fn store_memory_honors_namespace_query() {
3155        let (app, auth) = app_with_store().await;
3156        let body = serde_json::json!({
3157            "id": "mem_nsimp",
3158            "content": "imported via namespace query",
3159            "project": "ijima",
3160            "topic": "import",
3161            "source": "AutoCapture",
3162            "harness": "Pi",
3163            "importance": 0.5,
3164            "created_at": "0",
3165        })
3166        .to_string();
3167        let res = app
3168            .clone()
3169            .oneshot(
3170                Request::builder()
3171                    .method("POST")
3172                    .uri("/memories?namespace=ns_import_testbox")
3173                    .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3174                    .header("content-type", "application/json")
3175                    .body(Body::from(body))
3176                    .unwrap(),
3177            )
3178            .await
3179            .unwrap();
3180        assert_eq!(res.status(), StatusCode::OK);
3181
3182        // Dedup check in that namespace finds it; the caller's personal
3183        // namespace does not (isolation held).
3184        let read_token = bearer(&auth, "elliott", MEMORY_READ);
3185        let check = |uri: &str| {
3186            let uri = uri.to_string();
3187            let app = app.clone();
3188            let body = serde_json::json!({
3189                "content": "imported via namespace query"
3190            })
3191            .to_string();
3192            let auth_header = read_token.clone();
3193            async move {
3194                app.oneshot(
3195                    Request::builder()
3196                        .method("POST")
3197                        .uri(uri)
3198                        .header("authorization", auth_header)
3199                        .header("content-type", "application/json")
3200                        .body(Body::from(body))
3201                        .unwrap(),
3202                )
3203                .await
3204                .unwrap()
3205            }
3206        };
3207        let res = check("/memories/check?namespace=ns_import_testbox").await;
3208        assert_eq!(res.status(), StatusCode::OK);
3209        let found = body_json(res).await;
3210        assert_eq!(
3211            found["duplicate"].as_str(),
3212            Some("mem_nsimp"),
3213            "same-namespace dedup check must find the import"
3214        );
3215        let res = check("/memories/check").await;
3216        assert_eq!(res.status(), StatusCode::OK);
3217        let personal = body_json(res).await;
3218        assert_eq!(
3219            personal["duplicate"].as_str(),
3220            None,
3221            "personal namespace must not see the import"
3222        );
3223    }
3224
3225    #[tokio::test]
3226    async fn store_memory_rejects_foreign_private_namespace() {
3227        let (app, auth) = app_with_store().await;
3228        let body = serde_json::json!({
3229            "id": "mem_sneaky",
3230            "content": "cross-tenant write attempt",
3231            "project": "ijima",
3232            "topic": "security",
3233            "source": "Explicit",
3234            "harness": "Pi",
3235            "importance": 0.5,
3236            "created_at": "0",
3237        })
3238        .to_string();
3239        let res = app
3240            .oneshot(
3241                Request::builder()
3242                    .method("POST")
3243                    .uri("/memories?namespace=ns_bob_private")
3244                    .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3245                    .header("content-type", "application/json")
3246                    .body(Body::from(body))
3247                    .unwrap(),
3248            )
3249            .await
3250            .unwrap();
3251        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3252    }
3253
3254    #[tokio::test]
3255    async fn rooms_taxonomy_stats_reflect_seeded_memories() {
3256        let (app, auth) = app_with_store().await;
3257        seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3258        seed_memory(&app, &auth, "mem_b", "ijima", "auth").await;
3259        let read = bearer(&auth, "elliott", MEMORY_READ);
3260
3261        // /rooms
3262        let res = app
3263            .clone()
3264            .oneshot(
3265                Request::builder()
3266                    .uri("/rooms")
3267                    .header("authorization", &read)
3268                    .body(Body::empty())
3269                    .unwrap(),
3270            )
3271            .await
3272            .unwrap();
3273        assert_eq!(res.status(), StatusCode::OK);
3274        let rooms = body_json(res).await;
3275        let topics: std::collections::HashSet<&str> = rooms
3276            .as_array()
3277            .unwrap()
3278            .iter()
3279            .map(|r| r["topic"].as_str().unwrap())
3280            .collect();
3281        assert!(
3282            topics.contains("api") && topics.contains("auth"),
3283            "rooms: {rooms}"
3284        );
3285
3286        // /memories/stats
3287        let res = app
3288            .clone()
3289            .oneshot(
3290                Request::builder()
3291                    .uri("/memories/stats")
3292                    .header("authorization", &read)
3293                    .body(Body::empty())
3294                    .unwrap(),
3295            )
3296            .await
3297            .unwrap();
3298        assert_eq!(res.status(), StatusCode::OK);
3299        let stats = body_json(res).await;
3300        assert_eq!(stats["total"], 2, "stats: {stats}");
3301        assert_eq!(stats["projects"][0]["project"], "ijima");
3302        assert_eq!(stats["projects"][0]["count"], 2);
3303    }
3304
3305    #[tokio::test]
3306    async fn browse_memories_filters_by_project() {
3307        let (app, auth) = app_with_store().await;
3308        seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3309        seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3310        let read = bearer(&auth, "elliott", MEMORY_READ);
3311
3312        let res = app
3313            .clone()
3314            .oneshot(
3315                Request::builder()
3316                    .uri("/memories?project=possum")
3317                    .header("authorization", &read)
3318                    .body(Body::empty())
3319                    .unwrap(),
3320            )
3321            .await
3322            .unwrap();
3323        assert_eq!(res.status(), StatusCode::OK);
3324        let mems = body_json(res).await;
3325        let arr = mems.as_array().unwrap();
3326        assert_eq!(arr.len(), 1);
3327        assert_eq!(arr[0]["project"], "possum");
3328    }
3329
3330    #[tokio::test]
3331    async fn palace_graph_and_tunnel_link_shared_topic() {
3332        let (app, auth) = app_with_store().await;
3333        seed_memory(&app, &auth, "mem_a", "ijima", "efficiency").await;
3334        seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3335        let read = bearer(&auth, "elliott", MEMORY_READ);
3336
3337        let res = app
3338            .clone()
3339            .oneshot(
3340                Request::builder()
3341                    .uri("/palace/graph")
3342                    .header("authorization", &read)
3343                    .body(Body::empty())
3344                    .unwrap(),
3345            )
3346            .await
3347            .unwrap();
3348        assert_eq!(res.status(), StatusCode::OK);
3349        let graph = body_json(res).await;
3350        let projects: std::collections::HashSet<&str> = graph["projects"]
3351            .as_array()
3352            .unwrap()
3353            .iter()
3354            .map(|p| p.as_str().unwrap())
3355            .collect();
3356        assert!(
3357            projects.contains("ijima") && projects.contains("possum"),
3358            "graph: {graph}"
3359        );
3360
3361        let res = app
3362            .clone()
3363            .oneshot(
3364                Request::builder()
3365                    .uri("/palace/tunnel?topic=efficiency&project_a=ijima&project_b=possum")
3366                    .header("authorization", &read)
3367                    .body(Body::empty())
3368                    .unwrap(),
3369            )
3370            .await
3371            .unwrap();
3372        assert_eq!(res.status(), StatusCode::OK);
3373        let trav = body_json(res).await;
3374        assert_eq!(trav["memories_a"].as_array().unwrap().len(), 1);
3375        assert_eq!(trav["memories_b"].as_array().unwrap().len(), 1);
3376    }
3377
3378    #[tokio::test]
3379    async fn diary_write_then_read_round_trips() {
3380        let (app, auth) = app_with_store().await;
3381        let write = bearer(&auth, "elliott", MEMORY_WRITE);
3382        let read = bearer(&auth, "elliott", MEMORY_READ);
3383
3384        let body = serde_json::json!({
3385            "agent": "pi",
3386            "content": "shipped the routes",
3387            "topic": "ijima",
3388            "timestamp": "2026-08-09T12:00:00Z"
3389        })
3390        .to_string();
3391        let res = app
3392            .clone()
3393            .oneshot(
3394                Request::builder()
3395                    .method("POST")
3396                    .uri("/diaries")
3397                    .header("authorization", &write)
3398                    .header("content-type", "application/json")
3399                    .body(Body::from(body))
3400                    .unwrap(),
3401            )
3402            .await
3403            .unwrap();
3404        assert_eq!(res.status(), StatusCode::NO_CONTENT);
3405
3406        let res = app
3407            .clone()
3408            .oneshot(
3409                Request::builder()
3410                    .uri("/diaries/pi")
3411                    .header("authorization", &read)
3412                    .body(Body::empty())
3413                    .unwrap(),
3414            )
3415            .await
3416            .unwrap();
3417        assert_eq!(res.status(), StatusCode::OK);
3418        let entries = body_json(res).await;
3419        let arr = entries.as_array().unwrap();
3420        assert_eq!(arr.len(), 1);
3421        assert_eq!(arr[0]["content"], "shipped the routes");
3422    }
3423
3424    #[tokio::test]
3425    async fn diary_write_requires_memory_write_not_read() {
3426        let (app, auth) = app_with_store().await;
3427        let read = bearer(&auth, "elliott", MEMORY_READ);
3428        let body = serde_json::json!({"agent": "pi", "content": "x", "timestamp": "t"}).to_string();
3429        let res = app
3430            .clone()
3431            .oneshot(
3432                Request::builder()
3433                    .method("POST")
3434                    .uri("/diaries")
3435                    .header("authorization", &read)
3436                    .header("content-type", "application/json")
3437                    .body(Body::from(body))
3438                    .unwrap(),
3439            )
3440            .await
3441            .unwrap();
3442        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3443    }
3444
3445    #[tokio::test]
3446    async fn repo_register_list_resolve_round_trips() {
3447        let (app, auth) = app_with_store().await;
3448        let admin = bearer(&auth, "elliott", ADMIN);
3449        let read = bearer(&auth, "elliott", MEMORY_READ);
3450
3451        // register a repo (admin)
3452        let body = serde_json::json!({
3453            "name": "Ijima",
3454            "path": "/home/x/Ijima",
3455            "remote_url": "git@github.com:Industrial-Algebra/Ijima.git",
3456            "role": "memory-service"
3457        })
3458        .to_string();
3459        let res = app
3460            .clone()
3461            .oneshot(
3462                Request::builder()
3463                    .method("POST")
3464                    .uri("/repos")
3465                    .header("authorization", &admin)
3466                    .header("content-type", "application/json")
3467                    .body(Body::from(body))
3468                    .unwrap(),
3469            )
3470            .await
3471            .unwrap();
3472        assert_eq!(res.status(), StatusCode::NO_CONTENT);
3473
3474        // list (memory:read)
3475        let res = app
3476            .clone()
3477            .oneshot(
3478                Request::builder()
3479                    .uri("/repos")
3480                    .header("authorization", &read)
3481                    .body(Body::empty())
3482                    .unwrap(),
3483            )
3484            .await
3485            .unwrap();
3486        assert_eq!(res.status(), StatusCode::OK);
3487        let repos = body_json(res).await;
3488        assert_eq!(repos[0]["name"], "Ijima");
3489        assert_eq!(repos[0]["path"], "/home/x/Ijima");
3490
3491        // resolve a cwd inside the repo (memory:read)
3492        let res = app
3493            .clone()
3494            .oneshot(
3495                Request::builder()
3496                    .uri("/repos/resolve?cwd=/home/x/Ijima/src")
3497                    .header("authorization", &read)
3498                    .body(Body::empty())
3499                    .unwrap(),
3500            )
3501            .await
3502            .unwrap();
3503        assert_eq!(res.status(), StatusCode::OK);
3504        let repo = body_json(res).await;
3505        assert_eq!(repo["name"], "Ijima");
3506
3507        // resolve a cwd in no registered repo → 404
3508        let res = app
3509            .clone()
3510            .oneshot(
3511                Request::builder()
3512                    .uri("/repos/resolve?cwd=/nowhere/here")
3513                    .header("authorization", &read)
3514                    .body(Body::empty())
3515                    .unwrap(),
3516            )
3517            .await
3518            .unwrap();
3519        assert_eq!(res.status(), StatusCode::NOT_FOUND);
3520    }
3521
3522    #[tokio::test]
3523    async fn repo_register_requires_admin() {
3524        let (app, auth) = app_with_store().await;
3525        let read = bearer(&auth, "elliott", MEMORY_READ);
3526        let body = serde_json::json!({
3527            "name": "X", "path": "/x", "remote_url": "u", "role": "r"
3528        })
3529        .to_string();
3530        let res = app
3531            .clone()
3532            .oneshot(
3533                Request::builder()
3534                    .method("POST")
3535                    .uri("/repos")
3536                    .header("authorization", &read)
3537                    .header("content-type", "application/json")
3538                    .body(Body::from(body))
3539                    .unwrap(),
3540            )
3541            .await
3542            .unwrap();
3543        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3544    }
3545
3546    #[tokio::test]
3547    async fn token_revocation_kills_the_bearer_immediately() {
3548        let (app, auth) = app_with_store().await;
3549        let admin = bearer(&auth, "op", ADMIN);
3550        let victim = bearer(&auth, "victim", MEMORY_READ);
3551
3552        // Victim can read before revocation.
3553        let res = app
3554            .clone()
3555            .oneshot(
3556                Request::builder()
3557                    .uri("/memories")
3558                    .header("authorization", &victim)
3559                    .body(Body::empty())
3560                    .unwrap(),
3561            )
3562            .await
3563            .unwrap();
3564        assert_eq!(res.status(), StatusCode::OK);
3565
3566        // Non-admin cannot revoke.
3567        let res = app
3568            .clone()
3569            .oneshot(
3570                Request::builder()
3571                    .method("POST")
3572                    .uri("/tokens/revoke")
3573                    .header("authorization", &victim)
3574                    .header("content-type", "application/json")
3575                    .body(Body::from(
3576                        serde_json::json!({ "token": victim, "reason": "test" }).to_string(),
3577                    ))
3578                    .unwrap(),
3579            )
3580            .await
3581            .unwrap();
3582        assert_eq!(res.status(), StatusCode::FORBIDDEN);
3583
3584        // Admin revokes the victim's bearer.
3585        let res = app
3586            .clone()
3587            .oneshot(
3588                Request::builder()
3589                    .method("POST")
3590                    .uri("/tokens/revoke")
3591                    .header("authorization", &admin)
3592                    .header("content-type", "application/json")
3593                    .body(Body::from(
3594                        serde_json::json!({ "token": victim, "reason": "leaked in test" })
3595                            .to_string(),
3596                    ))
3597                    .unwrap(),
3598            )
3599            .await
3600            .unwrap();
3601        assert_eq!(res.status(), StatusCode::NO_CONTENT);
3602
3603        // The same bearer is now exactly as dead as a bad signature.
3604        let res = app
3605            .clone()
3606            .oneshot(
3607                Request::builder()
3608                    .uri("/memories")
3609                    .header("authorization", &victim)
3610                    .body(Body::empty())
3611                    .unwrap(),
3612            )
3613            .await
3614            .unwrap();
3615        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
3616
3617        // Admin can list the revocation — hash only, never the bearer.
3618        let res = app
3619            .clone()
3620            .oneshot(
3621                Request::builder()
3622                    .uri("/tokens/revocations")
3623                    .header("authorization", &admin)
3624                    .body(Body::empty())
3625                    .unwrap(),
3626            )
3627            .await
3628            .unwrap();
3629        assert_eq!(res.status(), StatusCode::OK);
3630        let body: serde_json::Value = serde_json::from_slice(
3631            &axum::body::to_bytes(res.into_body(), usize::MAX)
3632                .await
3633                .unwrap(),
3634        )
3635        .unwrap();
3636        let revs = body.as_array().expect("list response");
3637        assert_eq!(revs.len(), 1);
3638        assert_eq!(revs[0]["reason"], "leaked in test");
3639        assert_eq!(
3640            revs[0]["token_hash"].as_str().expect("hash"),
3641            crate::auth::bearer_hash(&victim)
3642        );
3643        assert!(!revs[0].to_string().contains(&victim), "no raw bearer");
3644
3645        // Revocation survives restart-by-rehydration: a fresh auth over
3646        // the same store re-arms (simulated via hydrate from the store).
3647        let listed: Vec<TokenRevocation> =
3648            serde_json::from_value(body).expect("deserializes as TokenRevocation");
3649        auth.hydrate_revocations(&listed);
3650        assert!(auth.is_revoked(&victim));
3651    }
3652}