Skip to main content

mnem_http/
handlers.rs

1//! Axum handlers for `mnem http`'s v1 surface.
2//!
3//! Keep all handlers synchronous inside the lock. We deliberately hold
4//! a `std::sync::Mutex` across blocking mnem-core calls rather than a
5//! `tokio::Mutex` because those calls don't await; the server is
6//! multi-threaded so concurrent readers serialise on the mutex but
7//! never on the async runtime.
8//
9// Remote-v0 insertion point: future remote-transport endpoints land
10// here as a parallel `/remote/v1/*` surface, NOT on `/v1/*`. Four
11// verbs:
12// GET /remote/v1/refs -> list refs + capabilities
13// POST /remote/v1/fetch-blocks -> stream a CAR of wanted blocks
14// POST /remote/v1/push-blocks -> accept a CAR, verify signatures
15// POST /remote/v1/advance-head -> CAS a ref to a new CID
16// The protocol is source-agnostic: a hosted Uranid plane is one implementation,
17// self-hosted mnem http is another, `file://` is a third. See
18// `docs/ROADMAP.md#remote-v0-work-items-tracked-inline-in-src`
19// item 1 and ().
20
21use axum::Json;
22use axum::extract::{Path, Query, State};
23use axum::http::header::CONTENT_TYPE;
24use axum::response::IntoResponse;
25use ipld_core::ipld::Ipld;
26use mnem_core::codec::{from_canonical_bytes, json_to_ipld};
27use mnem_core::id::{EdgeId, NodeId};
28use mnem_core::index::PropPredicate;
29use mnem_core::objects::{Commit, Edge, Node, Operation};
30use mnem_core::retrieve::Lane;
31use mnem_core::{HEADS_PREFIX, TAGS_PREFIX};
32// BENCH-1 (C4): trait import is required so `MockEmbedder::embed`
33// and `::model` resolve on the concrete struct in the cold-start
34// fallback paths inside `retrieve` / `retrieve_full` below.
35use mnem_embed_providers::Embedder as _;
36use serde::{Deserialize, Serialize};
37use serde_json::{Map, Value, json};
38
39use crate::auth::RequireBearer;
40use crate::error::Error;
41use crate::state::AppState;
42
43// ---------- GET /v1/healthz ----------
44
45/// Canonical wire-name for a retrieval lane. Keep in sync with the
46/// `Lane` enum's variants; downstream docs / dashboards depend on
47/// these exact strings.
48const fn lane_name(lane: Lane) -> &'static str {
49    match lane {
50        Lane::Vector => "vector",
51        Lane::Sparse => "sparse",
52        Lane::GraphExpand => "graph_expand",
53        Lane::Rerank => "rerank",
54        // `Lane` is #[non_exhaustive]; new variants added upstream
55        // surface here as "unknown" rather than breaking the wire
56        // format. Downstream clients that see an unknown key should
57        // still be able to parse the response.
58        _ => "unknown",
59    }
60}
61
62// ---------- input clamps ----------
63//
64// The retrieve path takes three caller-controlled usize knobs:
65// `limit` (final result count), `vector_cap` (per-lane candidate
66// pool), and `rerank_top_k` (fanout into an external reranker).
67// None had a ceiling before R2-A. A caller could send
68// `limit=18446744073709551615` and trigger whatever the downstream
69// BruteForce vector search allocates - even behind the 64 MiB
70// body-size limit, `Option<usize>` is cheap to send and expensive
71// to honour. Reject at the boundary.
72//
73// The ceilings are deliberately generous: they exist to prevent
74// accidental or adversarial OOM, not to impose product shape.
75// Legitimate callers will never see them. If you have a real use
76// case that exceeds a cap, raise the cap here (not locally per
77// handler) and extend the 400 message with the new ceiling.
78
79/// Maximum `limit` accepted on any `/v1/retrieve` variant. Caps the
80/// final item count returned to the caller. 1,000 is ~20x the
81/// practical top-k a UI or LLM context window can consume.
82pub(crate) const MAX_RETRIEVE_LIMIT: usize = 1_000;
83
84/// Maximum `vector_cap` accepted on `POST /v1/retrieve`. Caps the
85/// per-lane candidate pool the vector index walks. 100,000 covers
86/// the entire legitimate dense-corpus fan-out for the current
87/// `BruteForce` index; HNSW will want its own tuning.
88pub(crate) const MAX_VECTOR_CAP: usize = 100_000;
89
90/// Maximum `rerank_top_k` accepted on `POST /v1/retrieve`. Caps
91/// the number of candidates sent to an external reranker. 500 is
92/// 10x what any cross-encoder today handles in <1s; callers
93/// usually pick 50-100.
94pub(crate) const MAX_RERANK_TOP_K: usize = 500;
95
96/// Reject an oversized `limit` / `vector_cap` / `rerank_top_k` with
97/// a 400 and a specific message that tells the caller which knob
98/// and which cap.
99fn clamp_or_reject(name: &'static str, value: Option<usize>, cap: usize) -> Result<(), Error> {
100    if let Some(n) = value
101        && n > cap
102    {
103        return Err(Error::bad_request(format!(
104            "{name}={n} exceeds max of {cap}; lower the value or split the request"
105        )));
106    }
107    Ok(())
108}
109
110pub(crate) async fn healthz() -> Json<Value> {
111    Json(json!({
112    "schema": "mnem.v1.healthz",
113    "ok": true,
114    "service": "mnem http",
115    "version": env!("CARGO_PKG_VERSION"),
116    }))
117}
118
119// ---------- GET /v1/stats ----------
120
121pub(crate) async fn stats(State(s): State<AppState>) -> Result<Json<Value>, Error> {
122    let repo = s.repo.lock().map_err(|_| Error::locked())?;
123    let op_id = repo.op_id().to_string();
124    let head = repo.view().heads.first().map(ToString::to_string);
125    let refs = repo.view().refs.len();
126    Ok(Json(json!({
127    "schema": "mnem.v1.stats",
128    "op_id": op_id,
129    "head_commit": head,
130    "refs": refs,
131    })))
132}
133
134// ---------- POST /v1/nodes ----------
135
136#[derive(Deserialize)]
137pub(crate) struct PostNodeBody {
138    /// Scoping tag. Maps to `Node.ntype` on the wire. Optional on the
139    /// HTTP boundary: if omitted or empty, the server substitutes
140    /// [`Node::DEFAULT_NTYPE`] (`"Node"`). Callers that want
141    /// per-tenant / per-conversation isolation pass a non-empty value.
142    #[serde(default)]
143    pub label: String,
144    pub summary: Option<String>,
145    pub props: Option<Map<String, Value>>,
146    pub content: Option<String>,
147    /// Required for the single-node `POST /v1/nodes` path; optional
148    /// inside the bulk wrapper (audit-2026-04-25 P2-8): when absent,
149    /// the wrapper-level `author` is used. The single-node handler
150    /// still validates non-empty before commit, so the contract is
151    /// preserved.
152    #[serde(default)]
153    pub author: Option<String>,
154    #[serde(default)]
155    pub message: Option<String>,
156    /// Optional caller-supplied UUID. When present, mnem uses it as
157    /// the node's `NodeId` instead of generating a fresh v7. Lets
158    /// distributed agents + replay pipelines pin node identity so
159    /// two machines ingesting the same logical event produce the
160    /// same `Node` CID. Must be a UUID-8x20 / UUID-v7 / UUID-v4
161    /// string parseable by `NodeId::parse_uuid`.
162    #[serde(default)]
163    pub id: Option<String>,
164}
165
166#[derive(Serialize)]
167pub(crate) struct PostNodeResp {
168    schema: &'static str,
169    id: String,
170    label: String,
171    op_id: String,
172}
173
174pub(crate) async fn post_node(
175    State(s): State<AppState>,
176    Json(body): Json<PostNodeBody>,
177) -> Result<Json<PostNodeResp>, Error> {
178    // Two-step label resolution:
179    // 1. If the server was not launched with `MNEM_BENCH=1`
180    // (`s.allow_labels == false`), we *ignore* any caller-supplied
181    // `label` silently and always use `Node::DEFAULT_NTYPE`. This
182    // is the casual / single-tenant path: no label surface.
183    // 2. If `s.allow_labels == true`, we honour the caller's value;
184    // an empty/omitted value still falls back to the default.
185    let label = if s.allow_labels && !body.label.trim().is_empty() {
186        body.label.clone()
187    } else {
188        Node::DEFAULT_NTYPE.to_string()
189    };
190    let author = body
191        .author
192        .as_deref()
193        .map(str::trim)
194        .filter(|a| !a.is_empty())
195        .map(str::to_string);
196    let author = match author {
197        Some(a) => a,
198        None => return Err(Error::bad_request("author is required")),
199    };
200
201    let node_id = match body.id.as_deref() {
202        Some(s) => NodeId::parse_uuid(s)
203            .map_err(|e| Error::bad_request(format!("invalid caller-supplied id: {e}")))?,
204        None => NodeId::new_v7(),
205    };
206    let mut node = Node::new(node_id, &label);
207    if let Some(sum) = &body.summary {
208        node = node.with_summary(sum);
209    }
210    if let Some(props) = body.props {
211        for (k, v) in props {
212            node = node.with_prop(
213                k,
214                json_to_ipld(&v).map_err(|e| Error::bad_request(e.to_string()))?,
215            );
216        }
217    }
218    if let Some(c) = body.content {
219        node = node.with_content(bytes::Bytes::from(c.into_bytes()));
220    }
221
222    // Auto-embed the node's summary (dense + sparse, if configured).
223    // Failures warn but do not block the commit; a later `mnem embed`
224    // pass can backfill. Dense vectors stage to `Commit.embeddings`
225    // via `Transaction::set_embedding`; sparse vectors stage to
226    // `Commit.sparse` via `Transaction::set_sparse_embedding`.
227    // Neither touches the Node bytes, keeping `NodeCid` stable across
228    // encoder versions and federated peers (G16/G17).
229    let text_for_embed: Option<String> = node
230        .summary
231        .as_ref()
232        .filter(|t| !t.trim().is_empty())
233        .cloned();
234    let mut pending_dense: Option<(String, mnem_core::objects::Embedding)> = None;
235    let mut pending_sparse: Option<(String, mnem_core::sparse::SparseEmbed)> = None;
236    if let Some(text) = text_for_embed {
237        if let Some(pc) = &s.embed_cfg
238            && let Ok(embedder) = mnem_embed_providers::open(pc)
239            && let Ok(v) = embedder.embed(&text)
240        {
241            let emb = mnem_embed_providers::to_embedding(embedder.model(), &v);
242            pending_dense = Some((embedder.model().to_string(), emb));
243        }
244        if let Some(sc) = &s.sparse_cfg
245            && let Ok(sparser) = mnem_sparse_providers::open(sc)
246            && let Ok(se) = sparser.encode(&text)
247        {
248            pending_sparse = Some((sparser.vocab_id().to_string(), se));
249        }
250        // Silent on failure; the POST path returns an `id` either way.
251    }
252
253    let id = node.id;
254
255    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
256    let mut tx = guard.start_transaction();
257    let cid = tx.add_node(&node)?;
258    if let Some((model, emb)) = pending_dense {
259        tx.set_embedding(cid.clone(), model, emb)?;
260    }
261    if let Some((vocab_id, se)) = pending_sparse {
262        tx.set_sparse_embedding(cid, vocab_id, se)?;
263    }
264    let commit_start = std::time::Instant::now();
265    let new_repo = tx.commit(
266        &author,
267        body.message.as_deref().unwrap_or("mnem http add node"),
268    )?;
269    s.metrics
270        .commit_duration
271        .observe(commit_start.elapsed().as_secs_f64());
272    let op_id = new_repo.op_id().to_string();
273    *guard = new_repo;
274
275    Ok(Json(PostNodeResp {
276        schema: "mnem.v1.post-node",
277        id: id.to_uuid_string(),
278        label: body.label,
279        op_id,
280    }))
281}
282
283// ---------- GET /v1/nodes/{id} ----------
284
285pub(crate) async fn get_node(
286    State(s): State<AppState>,
287    Path(id_str): Path<String>,
288) -> Result<Json<Value>, Error> {
289    let id = NodeId::parse_uuid(&id_str)
290        .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
291    let repo = s.repo.lock().map_err(|_| Error::locked())?;
292    let node = repo
293        .lookup_node(&id)?
294        .ok_or_else(|| Error::not_found(format!("no node with id={id_str}")))?;
295
296    let mut props_map = Map::new();
297    for (k, v) in &node.props {
298        props_map.insert(k.clone(), ipld_to_json(v));
299    }
300
301    // Embeddings are sidecar-attached, not Node-inline. Probe under
302    // the configured embedder's `model_fq` (the same string used at
303    // write time). When no embed-provider is configured, we report
304    // `has_embedding = false` rather than enumerate every model -
305    // the sidecar API is keyed by model and a multi-model probe is
306    // out of scope for this single-flag wire field.
307    let has_embedding = match s.embed_cfg.as_ref() {
308        Some(pc) => {
309            let model = model_fq_of(pc);
310            let (_, node_cid) = mnem_core::codec::hash_to_cid(&node)
311                .map_err(|e| Error::internal(format!("hash node: {e}")))?;
312            repo.embedding_for(&node_cid, &model)?.is_some()
313        }
314        None => false,
315    };
316
317    Ok(Json(json!({
318    "schema": "mnem.v1.node",
319    "id": node.id.to_uuid_string(),
320    "label": node.ntype,
321    "summary": node.summary,
322    "props": Value::Object(props_map),
323    "content_bytes": node.content.as_ref().map_or(0, bytes::Bytes::len),
324    "has_embedding": has_embedding,
325    })))
326}
327
328/// Format the `provider:model` string the embedder adapters expose
329/// via `Embedder::model()`. Mirrored here so handlers can derive it
330/// from a `ProviderConfig` without opening the adapter.
331fn model_fq_of(pc: &mnem_embed_providers::ProviderConfig) -> String {
332    use mnem_embed_providers::ProviderConfig as PC;
333    match pc {
334        PC::Openai(c) => format!("openai:{}", c.model),
335        PC::Ollama(c) => format!("ollama:{}", c.model),
336        PC::Onnx(c) => format!("onnx:{}", c.model),
337    }
338}
339
340// ---------- GET /v1/nodes/{id}/embedding ----------
341
342/// Query parameters for `GET /v1/nodes/{id}/embedding`.
343#[derive(Deserialize)]
344pub(crate) struct GetNodeEmbeddingQuery {
345    /// Model identifier string (e.g. ``"onnx:all-MiniLM-L6-v2"``).
346    model: String,
347}
348
349/// Fetch the embedding vector for a node by UUID and model string.
350///
351/// Returns `200 OK` with the embedding in the `mnem.v1.node_embedding`
352/// schema, or `404 Not Found` when the node or embedding does not exist.
353pub(crate) async fn get_node_embedding(
354    State(s): State<AppState>,
355    Path(id_str): Path<String>,
356    Query(q): Query<GetNodeEmbeddingQuery>,
357) -> Result<Json<Value>, Error> {
358    let id = NodeId::parse_uuid(&id_str)
359        .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
360    let repo = s.repo.lock().map_err(|_| Error::locked())?;
361    let node = repo
362        .lookup_node(&id)?
363        .ok_or_else(|| Error::not_found(format!("no node with id={id_str}")))?;
364
365    let (_, node_cid) = mnem_core::codec::hash_to_cid(&node)
366        .map_err(|e| Error::internal(format!("hash node: {e}")))?;
367
368    let emb = repo.embedding_for(&node_cid, &q.model)?.ok_or_else(|| {
369        Error::not_found(format!(
370            "no embedding for model={} on node {}",
371            q.model, id_str
372        ))
373    })?;
374
375    let bytes = emb.vector.as_ref();
376    let vector: Vec<f32> = bytes
377        .chunks_exact(4)
378        .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
379        .collect();
380
381    let dtype_str = match emb.dtype {
382        mnem_core::objects::Dtype::F32 => "f32",
383        mnem_core::objects::Dtype::F16 => "f16",
384        mnem_core::objects::Dtype::F64 => "f64",
385        mnem_core::objects::Dtype::I8 => "i8",
386    };
387
388    Ok(Json(json!({
389        "schema": "mnem.v1.node_embedding",
390        "node_id": id_str,
391        "model": emb.model,
392        "dim": emb.dim,
393        "dtype": dtype_str,
394        "vector": vector,
395    })))
396}
397
398// ---------- DELETE /v1/nodes/{id} ----------
399
400#[derive(Deserialize)]
401pub(crate) struct DeleteQuery {
402    /// Commit author. Required; query-string rather than body so `curl
403    /// -X DELETE` stays one-line-trivial.
404    pub author: String,
405    #[serde(default)]
406    pub message: Option<String>,
407}
408
409pub(crate) async fn delete_node(
410    _auth: RequireBearer,
411    State(s): State<AppState>,
412    Path(id_str): Path<String>,
413    Query(q): Query<DeleteQuery>,
414) -> Result<Json<Value>, Error> {
415    let id = NodeId::parse_uuid(&id_str)
416        .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
417    if q.author.trim().is_empty() {
418        return Err(Error::bad_request("author is required"));
419    }
420
421    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
422    let existed = guard.lookup_node(&id)?.is_some();
423    if !existed {
424        return Err(Error::not_found(format!(
425            "no node with id={id_str} in current view"
426        )));
427    }
428    let mut tx = guard.start_transaction();
429    tx.remove_node(id);
430    let commit_start = std::time::Instant::now();
431    let new_repo = tx.commit(
432        &q.author,
433        q.message.as_deref().unwrap_or("mnem http delete node"),
434    )?;
435    s.metrics
436        .commit_duration
437        .observe(commit_start.elapsed().as_secs_f64());
438    let op_id = new_repo.op_id().to_string();
439    *guard = new_repo;
440
441    Ok(Json(json!({
442    "schema": "mnem.v1.delete-node",
443    "id": id_str,
444    "existed": true,
445    "op_id": op_id,
446    })))
447}
448
449// ---------- POST /v1/nodes/{id}/tombstone ----------
450
451#[derive(Deserialize)]
452pub(crate) struct TombstoneBody {
453    /// Free-form reason string recorded on the tombstone.
454    #[serde(default)]
455    pub reason: String,
456    /// Commit author.
457    pub author: String,
458}
459
460pub(crate) async fn tombstone_node(
461    State(s): State<AppState>,
462    Path(id_str): Path<String>,
463    Json(body): Json<TombstoneBody>,
464) -> Result<Json<Value>, Error> {
465    let id = NodeId::parse_uuid(&id_str)
466        .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
467    if body.author.trim().is_empty() {
468        return Err(Error::bad_request("author is required"));
469    }
470    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
471    // 404: the underlying node must exist in the current head. We
472    // check before starting a transaction so the error surface is
473    // clean (no "stale" commit on a missing id).
474    if guard.lookup_node(&id)?.is_none() {
475        return Err(Error::not_found(format!("no node with id={id_str}")));
476    }
477    // 409: already tombstoned. Matches the item-3 contract: callers
478    // shouldn't be able to re-tombstone silently via the HTTP API
479    // (the in-process `tombstone_node` remains idempotent for agents
480    // that want that behaviour).
481    if guard.is_tombstoned(&id) {
482        return Err(Error::conflict(format!(
483            "node {id_str} is already tombstoned"
484        )));
485    }
486    let mut tx = guard.start_transaction();
487    tx.tombstone_node(id, body.reason.clone())?;
488    let commit_start = std::time::Instant::now();
489    let new_repo = tx.commit(&body.author, "mnem http tombstone node")?;
490    s.metrics
491        .commit_duration
492        .observe(commit_start.elapsed().as_secs_f64());
493    let op_id = new_repo.op_id().to_string();
494    *guard = new_repo;
495
496    Ok(Json(json!({
497    "schema": "mnem.v1.tombstone",
498    "op_id": op_id,
499    "node_id": id_str,
500    })))
501}
502
503// ---------- POST /v1/edges ----------
504
505/// Request body for `POST /v1/edges`.
506#[derive(Deserialize)]
507pub(crate) struct PostEdgeBody {
508    /// UUID of the source node.
509    pub src: String,
510    /// UUID of the destination node.
511    pub dst: String,
512    /// Edge-type label (e.g. `"knows"`, `"works_at"`, `"cites"`).
513    pub etype: String,
514    /// Optional edge properties. Values must be JSON-serialisable IPLD.
515    #[serde(default)]
516    pub props: Option<Map<String, Value>>,
517    /// Commit author. Required.
518    pub author: String,
519    /// Optional commit message. Defaults to `"mnem http add edge"`.
520    #[serde(default)]
521    pub message: Option<String>,
522}
523
524/// `POST /v1/edges` - commit a new directed edge between two existing nodes.
525///
526/// Returns `{"schema":"mnem.v1.post-edge","edge_id":"<uuid>","op_id":"<cid>"}`.
527/// Returns 404 if `src` or `dst` does not exist in the current view.
528/// Returns 400 for malformed UUIDs, empty `etype`, or missing `author`.
529pub(crate) async fn post_edge(
530    _auth: RequireBearer,
531    State(s): State<AppState>,
532    Json(body): Json<PostEdgeBody>,
533) -> Result<Json<Value>, Error> {
534    // Validate required fields.
535    let author = body.author.trim();
536    if author.is_empty() {
537        return Err(Error::bad_request("author is required"));
538    }
539    if body.etype.trim().is_empty() {
540        return Err(Error::bad_request("etype is required"));
541    }
542
543    // Parse UUIDs.
544    let src = NodeId::parse_uuid(&body.src)
545        .map_err(|e| Error::bad_request(format!("invalid src UUID: {e}")))?;
546    let dst = NodeId::parse_uuid(&body.dst)
547        .map_err(|e| Error::bad_request(format!("invalid dst UUID: {e}")))?;
548
549    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
550
551    // C8: validate both endpoints exist before writing.
552    if guard.lookup_node(&src)?.is_none() {
553        return Err(Error::not_found(format!(
554            "no node with id={} (src)",
555            body.src
556        )));
557    }
558    if guard.lookup_node(&dst)?.is_none() {
559        return Err(Error::not_found(format!(
560            "no node with id={} (dst)",
561            body.dst
562        )));
563    }
564
565    let edge_id = EdgeId::new_v7();
566    let mut edge = Edge::new(edge_id, &body.etype, src, dst);
567    if let Some(props) = body.props {
568        for (k, v) in props {
569            edge = edge.with_prop(
570                k,
571                json_to_ipld(&v).map_err(|e| Error::bad_request(e.to_string()))?,
572            );
573        }
574    }
575
576    let mut tx = guard.start_transaction();
577    tx.add_edge(&edge)?;
578    let commit_start = std::time::Instant::now();
579    let new_repo = tx.commit(
580        author,
581        body.message.as_deref().unwrap_or("mnem http add edge"),
582    )?;
583    s.metrics
584        .commit_duration
585        .observe(commit_start.elapsed().as_secs_f64());
586    let op_id = new_repo.op_id().to_string();
587    *guard = new_repo;
588
589    Ok(Json(json!({
590        "schema": "mnem.v1.post-edge",
591        "edge_id": edge_id.to_uuid_string(),
592        "op_id": op_id,
593    })))
594}
595
596// ---------- POST /v1/nodes/bulk ----------
597//
598// One-commit bulk ingest. The per-node POST /v1/nodes path commits
599// after every write (Prolly-tree + IndexSet rebuild each time), which
600// is ~2 seconds per node on a laptop with ollama. At 3633 docs that
601// is two hours. The bulk endpoint accepts N nodes in one request and
602// does ONE commit at the end, dropping the same ingest to minutes.
603//
604// Response includes the per-node IDs in the order sent so callers
605// can build their external_id <-> mnem_node_id map.
606
607#[derive(Deserialize)]
608pub(crate) struct BulkNodeBody {
609    pub nodes: Vec<PostNodeBody>,
610    pub author: String,
611    #[serde(default)]
612    pub message: Option<String>,
613    /// When true (default) AND an embed provider is configured on the
614    /// server, each node's summary is auto-embedded before commit.
615    #[serde(default = "default_true")]
616    pub auto_embed: bool,
617}
618
619const fn default_true() -> bool {
620    true
621}
622
623#[derive(Serialize)]
624pub(crate) struct BulkNodeResp {
625    schema: &'static str,
626    op_id: String,
627    /// One entry per input node, same order.
628    results: Vec<BulkNodeEntry>,
629    /// How many nodes embedded successfully vs skipped.
630    embedded: u32,
631    skipped_embed: u32,
632}
633
634#[derive(Serialize)]
635pub(crate) struct BulkNodeEntry {
636    id: String,
637    label: String,
638}
639
640pub(crate) async fn post_nodes_bulk(
641    State(s): State<AppState>,
642    Json(body): Json<BulkNodeBody>,
643) -> Result<Json<BulkNodeResp>, Error> {
644    if body.author.trim().is_empty() {
645        return Err(Error::bad_request("author is required"));
646    }
647    if body.nodes.is_empty() {
648        return Err(Error::bad_request("nodes must not be empty"));
649    }
650
651    // Resolve the dense embedder + the sparse encoder once so we don't
652    // reopen per node. If a provider is configured but opening fails
653    // (bad API key, sidecar unreachable), fail the whole bulk call
654    // instead of committing every node without embeddings and
655    // silently reporting success.
656    let embedder = if body.auto_embed {
657        match s.embed_cfg.as_ref() {
658 Some(pc) => Some(mnem_embed_providers::open(pc).map_err(|e| {
659 Error::internal(format!(
660 "embed provider configured but open failed: {e}; bulk aborted to avoid silent no-embed commit"
661 ))
662 })?),
663 None => None,
664 }
665    } else {
666        None
667    };
668    let sparser = if body.auto_embed {
669        match s.sparse_cfg.as_ref() {
670 Some(sc) => Some(mnem_sparse_providers::open(sc).map_err(|e| {
671 Error::internal(format!(
672 "sparse provider configured but open failed: {e}; bulk aborted to avoid silent no-sparse commit"
673 ))
674 })?),
675 None => None,
676 }
677    } else {
678        None
679    };
680
681    // Pre-build every Node, doing the embed calls before taking the
682    // repo mutex. Ollama / OpenAI calls can be slow; holding the
683    // mutex across them would block every other HTTP request.
684    // Each entry pairs the Node with an optional dense (model, vec)
685    // staged for the sidecar-side `Transaction::set_embedding` call
686    // that runs after `add_node` returns the NodeCid.
687    type BuiltBulkNode = (
688        Node,
689        Option<(String, mnem_core::objects::Embedding)>,
690        Option<(String, mnem_core::sparse::SparseEmbed)>,
691    );
692    let mut built: Vec<BuiltBulkNode> = Vec::with_capacity(body.nodes.len());
693    let mut results: Vec<BulkNodeEntry> = Vec::with_capacity(body.nodes.len());
694    let mut embedded = 0u32;
695    let mut skipped_embed = 0u32;
696
697    for nb in body.nodes {
698        // Same gating as the single-node path: caller-supplied `label`
699        // is ignored unless the server was launched with
700        // `MNEM_BENCH=1`. See the doc-comment on `post_node` for the
701        // full rationale.
702        let label = if s.allow_labels && !nb.label.trim().is_empty() {
703            nb.label.clone()
704        } else {
705            Node::DEFAULT_NTYPE.to_string()
706        };
707        let node_id = match nb.id.as_deref() {
708            Some(s) => NodeId::parse_uuid(s)
709                .map_err(|e| Error::bad_request(format!("invalid caller-supplied id: {e}")))?,
710            None => NodeId::new_v7(),
711        };
712        let mut node = Node::new(node_id, &label);
713        if let Some(sum) = &nb.summary {
714            node = node.with_summary(sum);
715        }
716        if let Some(props) = nb.props {
717            for (k, v) in props {
718                node = node.with_prop(
719                    k,
720                    json_to_ipld(&v).map_err(|e| Error::bad_request(e.to_string()))?,
721                );
722            }
723        }
724        if let Some(c) = nb.content {
725            node = node.with_content(bytes::Bytes::from(c.into_bytes()));
726        }
727        // Dense and sparse vectors stage to their respective sidecars via
728        // `Transaction::set_embedding` / `set_sparse_embedding` after the
729        // commit loop knows the NodeCid; we collect both here keyed by
730        // position in `built`.
731        let text_for_embed: Option<String> = node
732            .summary
733            .as_ref()
734            .filter(|t| !t.trim().is_empty())
735            .cloned();
736        let mut pending_dense: Option<(String, mnem_core::objects::Embedding)> = None;
737        let mut pending_sparse_item: Option<(String, mnem_core::sparse::SparseEmbed)> = None;
738        if let Some(text) = text_for_embed {
739            if let Some(embedder) = embedder.as_ref() {
740                match embedder.embed(&text) {
741                    Ok(v) => {
742                        let emb = mnem_embed_providers::to_embedding(embedder.model(), &v);
743                        pending_dense = Some((embedder.model().to_string(), emb));
744                        embedded += 1;
745                    }
746                    Err(_) => {
747                        skipped_embed += 1;
748                    }
749                }
750            }
751            if let Some(sparser) = sparser.as_ref()
752                && let Ok(se) = sparser.encode(&text)
753            {
754                pending_sparse_item = Some((sparser.vocab_id().to_string(), se));
755            }
756        }
757        results.push(BulkNodeEntry {
758            id: node.id.to_uuid_string(),
759            label: nb.label,
760        });
761        built.push((node, pending_dense, pending_sparse_item));
762    }
763
764    // Single commit over all nodes. Index rebuild happens once.
765    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
766    let mut tx = guard.start_transaction();
767    for (node, pending_dense, pending_sparse_item) in &built {
768        let cid = tx.add_node(node)?;
769        if let Some((model, emb)) = pending_dense {
770            tx.set_embedding(cid.clone(), model.clone(), emb.clone())?;
771        }
772        if let Some((vocab_id, se)) = pending_sparse_item {
773            tx.set_sparse_embedding(cid, vocab_id.clone(), se.clone())?;
774        }
775    }
776    let commit_start = std::time::Instant::now();
777    let new_repo = tx.commit(
778        &body.author,
779        body.message.as_deref().unwrap_or("mnem http bulk add"),
780    )?;
781    s.metrics
782        .commit_duration
783        .observe(commit_start.elapsed().as_secs_f64());
784    let op_id = new_repo.op_id().to_string();
785    *guard = new_repo;
786
787    Ok(Json(BulkNodeResp {
788        schema: "mnem.v1.post-nodes-bulk",
789        op_id,
790        results,
791        embedded,
792        skipped_embed,
793    }))
794}
795
796// ---------- GET /v1/retrieve ----------
797
798#[derive(Deserialize)]
799pub(crate) struct RetrieveQuery {
800    pub text: Option<String>,
801    pub label: Option<String>,
802    #[serde(default)]
803    pub budget: Option<u32>,
804    #[serde(default)]
805    pub limit: Option<usize>,
806    /// `KEY=VALUE`; VALUE tried as JSON first, falls back to string.
807    pub where_eq: Option<String>,
808}
809
810pub(crate) async fn retrieve(
811    State(s): State<AppState>,
812    Query(q): Query<RetrieveQuery>,
813) -> Result<Json<Value>, Error> {
814    // Clamp untrusted numeric knobs before we touch the retriever.
815    // See the `MAX_RETRIEVE_LIMIT` / `MAX_VECTOR_CAP` / `MAX_RERANK_TOP_K`
816    // constants at the top of this file for rationale.
817    clamp_or_reject("limit", q.limit, MAX_RETRIEVE_LIMIT)?;
818
819    let repo = s.repo.lock().map_err(|_| Error::locked())?;
820    let mut ret = repo.retrieve();
821    // Honour the caller's label filter only when the server was
822    // launched with `MNEM_BENCH=1`. Otherwise the label field is
823    // simply ignored; the retrieve runs unscoped. See the
824    // `post_node` doc-comment for the full rationale.
825    if s.allow_labels
826        && let Some(l) = &q.label
827    {
828        ret = ret.label(l.clone());
829    }
830    if let Some(w) = &q.where_eq {
831        let (k, v) = parse_kv(w).map_err(Error::bad_request)?;
832        ret = ret.where_prop(k, PropPredicate::Eq(v));
833    }
834    if let Some(b) = q.budget {
835        ret = ret.token_budget(b);
836    }
837    if let Some(n) = q.limit {
838        ret = ret.limit(n);
839    }
840    // Auto-encode the text query through every configured lane
841    // (dense + sparse). there is no in-process lexical
842    // ranker left; a text query with no embedder AND no sparse
843    // provider configured is rejected with 400.
844    let mut vector_model: Option<String> = None;
845    let mut sparse_vocab: Option<String> = None;
846    if let Some(text) = q.text.as_deref()
847        && !text.trim().is_empty()
848    {
849        ret = ret.query_text(text.to_string());
850        // Dense lane.
851        if let Some(pc) = &s.embed_cfg {
852            let embedder = mnem_embed_providers::open(pc)
853                .map_err(|e| Error::internal(format!("embed provider open failed: {e}")))?;
854            let qvec = embedder
855                .embed(text)
856                .map_err(|e| Error::internal(format!("embed call failed: {e}")))?;
857            vector_model = Some(embedder.model().to_string());
858            ret = ret.vector(embedder.model().to_string(), qvec);
859        }
860        // Sparse lane.
861        if let Some(sc) = &s.sparse_cfg {
862            let sparser = mnem_sparse_providers::open(sc)
863                .map_err(|e| Error::bad_request(format!("sparse open failed: {e}")))?;
864            let sq = sparser
865                .encode_query(text)
866                .map_err(|e| Error::bad_request(format!("sparse encode failed: {e}")))?;
867            sparse_vocab = Some(sq.vocab_id.clone());
868            ret = ret.sparse_query(sq);
869        }
870        // BENCH-1 (C4 audit): cold-start fallback. Cells launched on
871        // a fresh `/data` volume have no `[embed]` / `[sparse]`
872        // section in `config.toml`, so AppState resolves both to
873        // `None`. Rather than 400 the caller (which breaks bench
874        // harnesses that exercise retrieve before configuring a
875        // provider), fall back to the deterministic, network-free
876        // `MockEmbedder` (blake3-derived, dim=384). Real providers
877        // still take priority when configured; this branch only
878        // fires when both `embed_cfg` AND `sparse_cfg` are absent.
879        if vector_model.is_none() && sparse_vocab.is_none() {
880            let mock = mnem_embed_providers::MockEmbedder::new("mock:cold-start-384", 384);
881            let qvec = mock
882                .embed(text)
883                .map_err(|e| Error::internal(format!("mock embed failed: {e}")))?;
884            vector_model = Some(mock.model().to_string());
885            ret = ret.vector(mock.model().to_string(), qvec);
886            tracing::warn!(
887                "retrieve: no [embed]/[sparse] configured; using deterministic \
888 MockEmbedder fallback (cold-start). Configure a real provider \
889 in config.toml for production retrieval quality."
890            );
891        }
892    }
893    {
894        let mut cache = s.indexes.lock().map_err(|_| Error::locked())?;
895        if let Some(model) = &vector_model {
896            let idx = cache.vector_index(&repo, model)?;
897            ret = ret.with_vector_index(idx);
898        }
899        if let Some(vocab) = &sparse_vocab {
900            let idx = cache.sparse_index(&repo, vocab)?;
901            ret = ret.with_sparse_index(idx);
902        }
903    }
904    // Record retrieve-latency histogram around the actual fusion call.
905    // This keeps the sample narrow (excludes JSON serialisation cost)
906    // so operators see the cost of the retrieval pipeline itself.
907    let retrieve_start = std::time::Instant::now();
908    let result = ret.execute()?;
909    s.metrics
910        .retrieve_latency
911        .observe(retrieve_start.elapsed().as_secs_f64());
912
913    let items: Vec<Value> = result
914        .items
915        .iter()
916        .map(|item| {
917            // Per-lane observability: expose as a JSON object keyed by
918            // lane name so API consumers can diagnose "why did this
919            // node rank" without re-running the pipeline locally.
920            let mut lane_obj = Map::new();
921            for (lane, score) in &item.lane_scores {
922                lane_obj.insert(lane_name(*lane).to_string(), json!(score));
923            }
924            json!({
925            "id": item.node.id.to_uuid_string(),
926            "label": item.node.ntype,
927            "score": item.score,
928            "tokens": item.tokens,
929            "summary": item.node.summary,
930            "rendered": item.rendered,
931            "lane_scores": Value::Object(lane_obj),
932            })
933        })
934        .collect();
935
936    // Gap 16: score calibration - scale-free per-query interpretability.
937    // `score_distribution` is a response-level block carrying
938    // min / max / median / iqr + a categorical `shape` label
939    // (long-tail / uniform / bimodal / insufficient-samples). The
940    // shape is promoted to a top-level agent hint per the R2 spec:
941    // agents consume it to decide whether top-1 is a confident match
942    // or whether the dense ranking is inconclusive. Scale-free: works
943    // identically for K=8 or K=1000.
944    let score_dist = {
945        let scores: Vec<f32> = result.items.iter().map(|it| it.score).collect();
946        mnem_graphrag::distribution_shape(&scores, mnem_graphrag::K_MIN)
947    };
948
949    Ok(Json(json!({
950    "schema": "mnem.v1.retrieve",
951    "items": items,
952    "tokens_used": result.tokens_used,
953    "tokens_budget": if result.tokens_budget == u32::MAX {
954    Value::Null
955    } else {
956    Value::from(result.tokens_budget)
957    },
958    "dropped": result.dropped,
959    "candidates_seen": result.candidates_seen,
960    "score_distribution": score_dist,
961    })))
962}
963
964// ---------- POST /v1/retrieve (full retrieval pipeline) ----------
965//
966// Accepts a JSON body with every knob the CLI exposes: label, where_eq,
967// text, budget, limit, vector_cap, graph_expand, rerank,
968// and hints that trigger the embedder / LLM at the edges.
969//
970// HyDE and multi-query require a configured LLM provider and are
971// gated behind explicit fields; the handler replies with `llm_skipped`
972// metadata when the caller asks for either without supplying a
973// provider config inline.
974//
975// Same adapter-failure policy as the CLI: every optional tier that
976// errors out is logged and skipped; the base hybrid retrieval always
977// runs.
978
979#[derive(Deserialize, Default)]
980pub(crate) struct RetrieveRequest {
981    // Basic filters
982    #[serde(default)]
983    pub text: Option<String>,
984    #[serde(default)]
985    pub label: Option<String>,
986    #[serde(default)]
987    pub where_eq: Option<String>,
988    #[serde(default)]
989    pub budget: Option<u32>,
990    #[serde(default)]
991    pub limit: Option<usize>,
992
993    // Ranker caps (fixes the hardcoded 256 silent truncation)
994    #[serde(default)]
995    pub vector_cap: Option<usize>,
996
997    // Semantic vector (caller may supply an embedding directly OR
998    // name an embedder configured on the server)
999    #[serde(default)]
1000    pub vector_model: Option<String>,
1001    #[serde(default)]
1002    pub vector: Option<Vec<f32>>,
1003
1004    // Tier 3: cross-encoder reranker, PROVIDER:MODEL
1005    #[serde(default)]
1006    pub rerank: Option<String>,
1007    #[serde(default)]
1008    pub rerank_top_k: Option<usize>,
1009
1010    // Experiment E1 (C3 FIX-1 v2): community **expander**. Despite
1011    // the legacy field name `community_filter`, this knob now wires
1012    // the ADDITIVE expander - it never drops candidates, only pulls
1013    // in community-cohesive siblings of the top seeds. Matrix v4
1014    // pinned a -29pp R@10 regression on the old drop-filter
1015    // semantic, which is why the semantic is inverted here. Flag
1016    // absent or `false` preserves the byte-exact pass-through
1017    // contract.
1018    #[serde(default)]
1019    pub community_filter: Option<bool>,
1020    /// Legacy knob retained for wire-compat with v0.1.0 clients.
1021    /// **Ignored** under the expander semantic: the expander has no
1022    /// coverage threshold because it never drops candidates.
1023    #[serde(default)]
1024    pub community_min_coverage: Option<f32>,
1025    /// Expander: number of top candidates treated as seeds whose
1026    /// communities get expanded. Default 3.
1027    #[serde(default)]
1028    pub community_expand_seeds: Option<usize>,
1029    /// Expander: per-community cap on how many additional members
1030    /// are pulled in. Default 10.
1031    #[serde(default)]
1032    pub community_max_per: Option<usize>,
1033    /// Expander: score decay applied to expanded members relative
1034    /// to the seed score. Default 0.85.
1035    #[serde(default)]
1036    pub community_decay: Option<f32>,
1037
1038    // Tier 2: graph expansion
1039    #[serde(default)]
1040    pub graph_expand: Option<usize>,
1041    #[serde(default)]
1042    pub graph_decay: Option<f32>,
1043    #[serde(default)]
1044    pub graph_etype: Option<Vec<String>>,
1045    /// Multi-hop traversal depth. `1` = single-hop (the classic
1046    /// graph-expand). `2+` enables MuSiQue-style compositional
1047    /// queries. Clamped internally to `[1, 4]`.
1048    #[serde(default)]
1049    pub graph_depth: Option<usize>,
1050    /// Per-seed outgoing-edge cap. Prevents a hot-seed node with
1051    /// hundreds of out-edges from starving sibling seeds in the
1052    /// global `graph_expand` budget.
1053    #[serde(default)]
1054    pub graph_max_per_seed: Option<usize>,
1055    /// Graph-expand strategy. `"decay"` (default) runs the classic
1056    /// BFS; `"ppr"` switches to personalised PageRank (E2+). PPR
1057    /// falls through to the decay walk when the repo has no wired
1058    /// adjacency index.
1059    #[serde(default)]
1060    pub graph_mode: Option<String>,
1061    /// PPR damping factor (default 0.85). Ignored unless
1062    /// `graph_mode = "ppr"`.
1063    #[serde(default)]
1064    pub ppr_damping: Option<f32>,
1065    /// PPR power-iteration cap (default 15). Ignored unless
1066    /// `graph_mode = "ppr"`.
1067    #[serde(default)]
1068    pub ppr_iter: Option<u32>,
1069    /// Gap 02 #17: opt in to running PPR even when the graph
1070    /// exceeds `PPR_DEFAULT_MAX_NODES` (250000). Default `false`
1071    /// (size gate active). Ignored unless `graph_mode = "ppr"`.
1072    #[serde(default)]
1073    pub ppr_opt_in: Option<bool>,
1074    /// E4 T2: Centroid + MMR extractive summarization on the top-M
1075    /// candidates. `summarize=false` (or absent) is a no-op; no
1076    /// `summary` field is emitted into the response.
1077    #[serde(default)]
1078    pub summarize: Option<bool>,
1079    /// How many summary sentences to emit. Defaults to 3 when
1080    /// `summarize=true` and this field is absent.
1081    #[serde(default)]
1082    pub summarize_k: Option<usize>,
1083}
1084
1085pub(crate) async fn retrieve_full(
1086    State(s): State<AppState>,
1087    Json(body): Json<RetrieveRequest>,
1088) -> Result<Json<Value>, Error> {
1089    // Clamp untrusted numeric knobs before we touch the retriever.
1090    // See the `MAX_RETRIEVE_LIMIT` / `MAX_VECTOR_CAP` / `MAX_RERANK_TOP_K`
1091    // constants at the top of this file for rationale.
1092    clamp_or_reject("limit", body.limit, MAX_RETRIEVE_LIMIT)?;
1093    clamp_or_reject("vector_cap", body.vector_cap, MAX_VECTOR_CAP)?;
1094    clamp_or_reject("rerank_top_k", body.rerank_top_k, MAX_RERANK_TOP_K)?;
1095
1096    let repo = s.repo.lock().map_err(|_| Error::locked())?;
1097    let mut ret = repo.retrieve();
1098    let mut skipped: Vec<String> = Vec::new();
1099    // Gap 14: structural warnings[]. Populated from compile-time
1100    // constants only (see `mnem_core::retrieve::warnings`). Every
1101    // push below is guarded by a structural precondition (substrate
1102    // count == 0, provider open error, etc.) so the array stays
1103    // small; `cap_warnings` enforces the hard cap before we
1104    // serialise.
1105    let mut warnings: Vec<mnem_core::retrieve::Warning> = Vec::new();
1106
1107    // Label filter gated by `MNEM_BENCH`. See `post_node` doc-comment
1108    // for the full rationale.
1109    if s.allow_labels
1110        && let Some(l) = &body.label
1111    {
1112        ret = ret.label(l.clone());
1113    }
1114    if let Some(w) = &body.where_eq {
1115        let (k, v) = parse_kv(w).map_err(Error::bad_request)?;
1116        ret = ret.where_prop(k, PropPredicate::Eq(v));
1117    }
1118    if let Some(b) = body.budget {
1119        ret = ret.token_budget(b);
1120    }
1121    if let Some(n) = body.limit {
1122        ret = ret.limit(n);
1123    }
1124    if let Some(n) = body.vector_cap {
1125        ret = ret.vector_cap(n);
1126    }
1127
1128    // Vector: caller-supplied embedding takes priority over
1129    // server-side auto-fuse. When no vector is supplied AND the
1130    // server has an embed provider configured, embed the text
1131    // query with it so the retrieve fires the real hybrid path.
1132    // This matches the CLI behaviour (commands.rs retrieve).
1133    //
1134    // Post-there is no text ranker left in mnem-core: a
1135    // `text` query without either (a) a caller-supplied vector or
1136    // (b) a configured embedder is rejected with 400.
1137    let mut vector_model: Option<String> = None;
1138    let mut sparse_vocab: Option<String> = None;
1139    if let Some(text) = body.text.as_deref()
1140        && !text.trim().is_empty()
1141    {
1142        ret = ret.query_text(text.to_string());
1143    }
1144    // Caller-supplied vector wins over auto-embed.
1145    if let (Some(m), Some(v)) = (&body.vector_model, &body.vector) {
1146        vector_model = Some(m.clone());
1147        ret = ret.vector(m.clone(), v.clone());
1148    } else if let Some(text) = body.text.as_deref()
1149        && !text.trim().is_empty()
1150        && let Some(pc) = &s.embed_cfg
1151    {
1152        let embedder = mnem_embed_providers::open(pc)
1153            .map_err(|e| Error::bad_request(format!("embed open failed: {e}")))?;
1154        let qvec = embedder
1155            .embed(text)
1156            .map_err(|e| Error::bad_request(format!("embed call failed: {e}")))?;
1157        vector_model = Some(embedder.model().to_string());
1158        ret = ret.vector(embedder.model().to_string(), qvec);
1159    }
1160    // Sparse lane: auto-encode via configured provider. Uses the
1161    // inference-free query path when the adapter overrides
1162    // `encode_query` (OpenSearch v3-distill).
1163    if let Some(text) = body.text.as_deref()
1164        && !text.trim().is_empty()
1165        && let Some(sc) = &s.sparse_cfg
1166    {
1167        let sparser = mnem_sparse_providers::open(sc)
1168            .map_err(|e| Error::internal(format!("sparse provider open failed: {e}")))?;
1169        let sq = sparser
1170            .encode_query(text)
1171            .map_err(|e| Error::internal(format!("sparse encode failed: {e}")))?;
1172        sparse_vocab = Some(sq.vocab_id.clone());
1173        ret = ret.sparse_query(sq);
1174    }
1175    // BENCH-1 (C4 audit): cold-start fallback. See sibling block in
1176    // `retrieve()` above for full rationale. When the caller passes a
1177    // text query, has supplied no inline `vector`, and the server has
1178    // no `[embed]` / `[sparse]` configured, fall back to the
1179    // deterministic `MockEmbedder` (blake3-derived, dim=384) instead
1180    // of returning 400. Adds a `skipped[]` entry + a structural
1181    // warning so callers see the degradation in the response.
1182    if body.text.as_deref().is_some_and(|t| !t.trim().is_empty())
1183        && vector_model.is_none()
1184        && sparse_vocab.is_none()
1185        && body.vector.is_none()
1186    {
1187        if let Some(text) = body.text.as_deref() {
1188            let mock = mnem_embed_providers::MockEmbedder::new("mock:cold-start-384", 384);
1189            let qvec = mock
1190                .embed(text)
1191                .map_err(|e| Error::internal(format!("mock embed failed: {e}")))?;
1192            vector_model = Some(mock.model().to_string());
1193            ret = ret.vector(mock.model().to_string(), qvec);
1194            skipped.push(
1195                "embed: cold-start MockEmbedder fallback (no [embed]/[sparse] configured)"
1196                    .to_string(),
1197            );
1198            tracing::warn!(
1199                "retrieve_full: no [embed]/[sparse] configured; using deterministic \
1200 MockEmbedder fallback (cold-start). Configure a real provider in \
1201 config.toml for production retrieval quality."
1202            );
1203        }
1204    }
1205
1206    // Attach cached indexes (audit fix G1): skip O(N) rebuild on every
1207    // retrieve by reusing per-commit-CID cached indexes. Commit
1208    // invalidation is automatic via op-id compare inside IndexCache.
1209    //
1210    // C3 Patch-B: also capture the vector-index handle so the
1211    // community_filter + ppr blocks below can feed it to the
1212    // `GraphCache` KNN-edge fallback when the authored adjacency is
1213    // empty (E0 wire activation).
1214    let mut vector_idx_for_graph: Option<std::sync::Arc<mnem_core::index::BruteForceVectorIndex>> =
1215        None;
1216    {
1217        let mut cache = s.indexes.lock().map_err(|_| Error::locked())?;
1218        if let Some(model) = &vector_model {
1219            let idx = cache.vector_index(&repo, model)?;
1220            vector_idx_for_graph = Some(idx.clone());
1221            ret = ret.with_vector_index(idx);
1222        }
1223        if let Some(vocab) = &sparse_vocab {
1224            let idx = cache.sparse_index(&repo, vocab)?;
1225            ret = ret.with_sparse_index(idx);
1226        }
1227    }
1228
1229    // Tier 3: rerank via adapter.
1230    if let Some(spec) = &body.rerank {
1231        match parse_rerank_spec(spec) {
1232            Ok(cfg) => match mnem_rerank_providers::open(&cfg) {
1233                Ok(rr) => {
1234                    ret = ret.with_reranker(rr);
1235                    if let Some(k) = body.rerank_top_k {
1236                        ret = ret.rerank_top_k(k);
1237                    }
1238                }
1239                Err(e) => {
1240                    skipped.push(format!("rerank: {e}"));
1241                    // Gap 14: structural warning. The detailed error
1242                    // goes on `skipped` (runtime string, includes
1243                    // provider diagnostics); the warning is the
1244                    // agent-routable compile-time-constant version.
1245                    warnings.push(mnem_core::retrieve::Warning::for_code(
1246                        mnem_core::retrieve::WarningCode::NoReranker,
1247                    ));
1248                }
1249            },
1250            Err(e) => {
1251                skipped.push(format!("rerank spec: {e}"));
1252                warnings.push(mnem_core::retrieve::Warning::for_code(
1253                    mnem_core::retrieve::WarningCode::NoReranker,
1254                ));
1255            }
1256        }
1257    }
1258
1259    // C3 FIX-1 v2: CommunityExpander runtime. When the caller sets
1260    // `community_filter: true` (legacy field name; the semantic is
1261    // now ADDITIVE expansion, not filter-drop), fetch (or build) a
1262    // Leiden community assignment over the authored-edges adjacency.
1263    // When that authored adjacency is empty (common under
1264    // `/v1/nodes/bulk` which does not author edges), fall back to a
1265    // deterministic KNN-edge substrate derived from the active vector
1266    // index (k=32, cosine). Expander is additive: it never drops
1267    // candidates, so worst case is neutral. Zero-impact when the flag
1268    // is absent or `false`.
1269    if body.community_filter.unwrap_or(false) {
1270        // Gap 14: detect substrate emptiness BEFORE building the
1271        // Leiden assignment. `has_vectors` is derived from the
1272        // already-captured `vector_idx_for_graph` handle;
1273        // `has_authored_edges` is derived from the graph_cache
1274        // adjacency slot which is populated lazily on first use.
1275        // Both checks are O(1) structural predicates.
1276        let has_vectors = vector_idx_for_graph
1277            .as_deref()
1278            .is_some_and(|v| !v.is_empty());
1279        let has_authored_edges = match s.graph_cache.lock() {
1280            Ok(gc) => gc.adjacency.as_ref().is_some_and(|a| !a.edges.is_empty()),
1281            Err(_) => false,
1282        };
1283        if !has_vectors && !has_authored_edges {
1284            warnings.push(mnem_core::retrieve::Warning::for_code(
1285                mnem_core::retrieve::WarningCode::CommunityFilterNoop,
1286            ));
1287        }
1288        let assignment = {
1289            let mut gc = s.graph_cache.lock().map_err(|_| Error::locked())?;
1290            gc.hybrid_community_for(&repo, vector_idx_for_graph.as_deref())?
1291        };
1292        let expand_seeds = body.community_expand_seeds.unwrap_or(3);
1293        let max_per_community = body.community_max_per.unwrap_or(10);
1294        let decay = body.community_decay.unwrap_or(0.85).clamp(0.0, 1.0);
1295        // min_coverage is retained on the DTO but ignored at runtime
1296        // (expander has no coverage threshold). We keep the value in
1297        // the cfg so debug logs reflect what the client sent.
1298        let min_coverage = body.community_min_coverage.unwrap_or(0.5).clamp(0.0, 1.0);
1299        let cfg = mnem_core::retrieve::CommunityFilterCfg {
1300            enabled: true,
1301            expand_seeds,
1302            max_per_community,
1303            decay,
1304            min_coverage,
1305        };
1306        let lookup_handle_fwd = assignment.clone();
1307        let lookup_handle_inv = assignment.clone();
1308        let lookup = std::sync::Arc::new(mnem_core::retrieve::CommunityLookup::new_with_members(
1309            move |nid| lookup_handle_fwd.community_of(*nid),
1310            move |cid| lookup_handle_inv.members_of(cid).to_vec(),
1311        ));
1312        ret = ret.with_community_filter(cfg, lookup);
1313    }
1314
1315    // C3 FIX-2 + Patch-B: HybridAdjacency + PPR wire. When
1316    // `graph_mode="ppr"`, fetch (or build) the adjacency snapshot and
1317    // install it as the retriever's adjacency index. Uses the same
1318    // KNN-edge fallback so PPR becomes a real traversal instead of the
1319    // identity-under-empty-adjacency no-op.
1320    let want_ppr = body
1321        .graph_mode
1322        .as_deref()
1323        .is_some_and(|m| m.eq_ignore_ascii_case("ppr"));
1324    if want_ppr {
1325        // Gap 14: substrate-emptiness warning for PPR. Same
1326        // precondition as community_filter; PPR on an empty
1327        // transition matrix is the identity pass.
1328        let has_vectors = vector_idx_for_graph
1329            .as_deref()
1330            .is_some_and(|v| !v.is_empty());
1331        let has_authored_edges = match s.graph_cache.lock() {
1332            Ok(gc) => gc.adjacency.as_ref().is_some_and(|a| !a.edges.is_empty()),
1333            Err(_) => false,
1334        };
1335        if !has_vectors && !has_authored_edges {
1336            warnings.push(mnem_core::retrieve::Warning::for_code(
1337                mnem_core::retrieve::WarningCode::PprNoSubstrate,
1338            ));
1339        }
1340        let adj = {
1341            let mut gc = s.graph_cache.lock().map_err(|_| Error::locked())?;
1342            gc.hybrid_adjacency_for(&repo, vector_idx_for_graph.as_deref())?
1343        };
1344        ret = ret.with_adjacency_index(adj);
1345    }
1346
1347    // Tier 2: graph expand (authored-graph traversal, mnem's moat).
1348    if let Some(max_expand) = body.graph_expand {
1349        // Gap 14: graph_expand reads authored edges only (not the
1350        // vector-derived KNN substrate). Emit a warning when the
1351        // authored adjacency is empty so the caller knows the walk
1352        // added nothing.
1353        let has_authored_edges = match s.graph_cache.lock() {
1354            Ok(gc) => gc.adjacency.as_ref().is_some_and(|a| !a.edges.is_empty()),
1355            Err(_) => false,
1356        };
1357        if !has_authored_edges {
1358            warnings.push(mnem_core::retrieve::Warning::for_code(
1359                mnem_core::retrieve::WarningCode::AuthoredAdjacencyEmpty,
1360            ));
1361        }
1362        let mut cfg = mnem_core::retrieve::GraphExpand {
1363            max_expand,
1364            decay: body
1365                .graph_decay
1366                .unwrap_or(mnem_core::retrieve::GraphExpand::DEFAULT_DECAY),
1367            etype_filter: body.graph_etype.clone(),
1368            ..Default::default()
1369        };
1370        if let Some(depth) = body.graph_depth {
1371            cfg = cfg.with_depth(depth);
1372        }
1373        if let Some(cap) = body.graph_max_per_seed {
1374            cfg = cfg.with_max_per_seed(cap);
1375        }
1376        // E2: PPR mode dispatch.
1377        if let Some(mode) = body.graph_mode.as_deref()
1378            && mode == "ppr"
1379        {
1380            let damping = body.ppr_damping.unwrap_or(mnem_core::ppr::DEFAULT_DAMPING);
1381            let iter = body.ppr_iter.unwrap_or(mnem_core::ppr::DEFAULT_MAX_ITER);
1382            cfg = cfg.with_ppr(damping, iter, mnem_core::ppr::DEFAULT_EPS);
1383        }
1384        ret = ret.with_graph_expand(cfg);
1385    }
1386
1387    // Gap 02 #17: forward the caller's `ppr_opt_in` knob. When the
1388    // caller pinned `true`, the retriever's PPR dispatcher skips the
1389    // default-on size gate. Default `false` means the gate is active
1390    // and oversized graphs fall back to decay.
1391    ret = ret.with_ppr_opt_in(body.ppr_opt_in.unwrap_or(false));
1392
1393    // Record retrieve-latency histogram around the fusion call itself.
1394    let retrieve_start = std::time::Instant::now();
1395    let result = ret.execute()?;
1396    s.metrics
1397        .retrieve_latency
1398        .observe(retrieve_start.elapsed().as_secs_f64());
1399
1400    // Gap 02 #17: if the retriever's PPR dispatcher tripped its
1401    // size gate, emit the structured warning and bump the labelled
1402    // counter. The gauge is initialized once in Metrics::new; no
1403    // per-request set is needed.
1404    if result.ppr_size_gate_skipped {
1405        warnings.push(mnem_core::retrieve::Warning::for_code(
1406            mnem_core::retrieve::WarningCode::PprSizeGateSkipped,
1407        ));
1408        s.metrics
1409            .ppr_size_gate_skipped
1410            .get_or_create(&crate::metrics::PprSizeGateLabels {
1411                reason: "above_threshold".into(),
1412            })
1413            .inc();
1414    }
1415    let items: Vec<Value> = result
1416        .items
1417        .iter()
1418        .map(|item| {
1419            // Per-lane observability: expose as a JSON object keyed by
1420            // lane name so API consumers can diagnose "why did this
1421            // node rank" without re-running the pipeline locally.
1422            let mut lane_obj = Map::new();
1423            for (lane, score) in &item.lane_scores {
1424                lane_obj.insert(lane_name(*lane).to_string(), json!(score));
1425            }
1426            json!({
1427            "id": item.node.id.to_uuid_string(),
1428            "label": item.node.ntype,
1429            "score": item.score,
1430            "tokens": item.tokens,
1431            "summary": item.node.summary,
1432            "rendered": item.rendered,
1433            "lane_scores": Value::Object(lane_obj),
1434            })
1435        })
1436        .collect();
1437
1438    // Gap 16: score calibration - scale-free per-query interpretability.
1439    // Mirrors the GET /v1/retrieve handler above. The `score_distribution`
1440    // block carries min / max / median / iqr + a categorical `shape`
1441    // label (long-tail / uniform / bimodal / insufficient-samples) so
1442    // agents can interpret the dense ranking without a trained scaler.
1443    let score_dist = {
1444        let scores: Vec<f32> = result.items.iter().map(|it| it.score).collect();
1445        mnem_graphrag::distribution_shape(&scores, mnem_graphrag::K_MIN)
1446    };
1447
1448    // E4 T2: optional Centroid + MMR extractive summarization over
1449    // the retrieved items' node summaries. Activated strictly by
1450    // `summarize: true` in the request body; absent / false = emit
1451    // no `summary` field at all (zero impact when off).
1452    // Gap 14: structural `warnings[]` array. Omitted when empty to
1453    // keep the wire clean; when non-empty, it is first passed through
1454    // `cap_warnings` to enforce the `WARNINGS_CAP` bound, substituting
1455    // the synthetic `warnings_truncated` entry for any overflow.
1456    let warnings = mnem_core::retrieve::cap_warnings(warnings);
1457    let warnings_json: Vec<Value> = warnings
1458        .iter()
1459        .map(|w| {
1460            json!({
1461            "code": w.code.as_str(),
1462            "knob": w.knob,
1463            "message": w.message,
1464            "remediation_ref": w.remediation_ref,
1465            })
1466        })
1467        .collect();
1468    // Gap 01 (agent-hop incentive): derive four response-metadata
1469    // fields so an LLM agent can decide whether to chase a hop
1470    // without re-running retrieve. All four are pure functions of
1471    // `result.items`; zero extra ranker calls, zero wire-breakage
1472    // for callers that ignore the new keys.
1473    //
1474    // * `confidence` = 1 - S(k)/S(1) over the top-K sorted scores
1475    // (rank-agreement). `0.0` on degenerate (len < 2 or top
1476    // score non-positive) inputs. Scale-free.
1477    // * `suggested_neighbors` = up to 3 items beyond the top-3 seeds
1478    // with a clipped preview and `via = "adjacency"`. Always a
1479    // strict subset of the ranked items (see proptest
1480    // `suggested_neighbors_always_subset_of_adjacency`).
1481    // * `community_density` = fraction of top-K items that share
1482    // the modal community of the top item. `0.0` when no
1483    // community assignment is wired; otherwise in `[0, 1]`.
1484    // * `session_reservoir_ttl_s` = live value of
1485    // `session_reservoir::IDLE_TTL` in seconds. Mirrors the
1486    // `mnem_session_reservoir_ttl_effective` gauge.
1487    let gap01_confidence = gap01_compute_confidence(&result.items);
1488    let gap01_neighbors = gap01_suggested_neighbors(&result.items);
1489    let gap01_community_density = 0.0_f32;
1490    let gap01_session_reservoir_ttl_s = mnem_core::retrieve::session_reservoir::IDLE_TTL.as_secs();
1491
1492    let mut response = json!({
1493    "schema": "mnem.v1.retrieve",
1494    "items": items,
1495    "tokens_used": result.tokens_used,
1496    "tokens_budget": if result.tokens_budget == u32::MAX {
1497    Value::Null
1498    } else {
1499    Value::from(result.tokens_budget)
1500    },
1501    "dropped": result.dropped,
1502    "score_distribution": score_dist,
1503    "candidates_seen": result.candidates_seen,
1504    "skipped": skipped,
1505    "confidence": gap01_confidence,
1506    "suggested_neighbors": gap01_neighbors,
1507    "community_density": gap01_community_density,
1508    "session_reservoir_ttl_s": gap01_session_reservoir_ttl_s,
1509    });
1510    if !warnings_json.is_empty() {
1511        response["warnings"] = Value::Array(warnings_json);
1512    }
1513
1514    if body.summarize.unwrap_or(false) {
1515        let k = body.summarize_k.unwrap_or(3).min(MAX_RETRIEVE_LIMIT);
1516        // C3 FIX-4: accumulate sentences AND a per-sentence
1517        // centrality vector in lockstep. When PPR was active
1518        // (graph_mode="ppr") we reuse the retriever's final item
1519        // score as a PPR-aware centrality proxy; else we fall
1520        // back to authored-edge degree from the graph_cache
1521        // adjacency; else a uniform 1.0 (identical to pre-E2).
1522        let mut sentences: Vec<String> = Vec::new();
1523        let mut centrality_weights: Vec<f32> = Vec::new();
1524        // Build an optional NodeId -> degree map once.
1525        let degree_map: Option<std::collections::HashMap<NodeId, u32>> = if want_ppr {
1526            // Degree is derived from the same authored snapshot the
1527            // retriever just saw; if it isn't cached we skip the
1528            // degree map rather than re-walk the repo here.
1529            if let Ok(gc) = s.graph_cache.lock() {
1530                gc.adjacency.as_ref().map(|adj| {
1531                    let mut m: std::collections::HashMap<NodeId, u32> =
1532                        std::collections::HashMap::new();
1533                    for (src, dst) in &adj.edges {
1534                        *m.entry(*src).or_insert(0) += 1;
1535                        *m.entry(*dst).or_insert(0) += 1;
1536                    }
1537                    m
1538                })
1539            } else {
1540                None
1541            }
1542        } else {
1543            None
1544        };
1545        for it in &result.items {
1546            if let Some(summary) = it.node.summary.clone() {
1547                sentences.push(summary);
1548                let w = if want_ppr {
1549                    // PPR-aware: use the final retrieve score
1550                    // (already PPR-propagated through graph_expand).
1551                    it.score.max(0.0)
1552                } else if let Some(m) = &degree_map {
1553                    m.get(&it.node.id).copied().unwrap_or(0) as f32
1554                } else {
1555                    1.0_f32
1556                };
1557                centrality_weights.push(w);
1558            }
1559        }
1560        // If no embedder is configured OR there are no sentences,
1561        // surface an empty summary and a skipped-reason; callers
1562        // can treat the absence of a non-empty summary the same
1563        // way they already handle missing rerank / HyDE.
1564        if sentences.is_empty() {
1565            response["summary"] = json!([]);
1566        } else if let Some(pc) = &s.embed_cfg {
1567            match mnem_embed_providers::open(pc) {
1568                Ok(embedder) => {
1569                    let centrality_vec = centrality_weights.clone();
1570                    let centrality =
1571                        move |i: usize| centrality_vec.get(i).copied().unwrap_or(1.0_f32);
1572                    match mnem_graphrag::summarize_community(
1573                        &sentences,
1574                        embedder.as_ref(),
1575                        None, // query vector optional; omitted at the HTTP edge for now
1576                        &centrality,
1577                        k,
1578                        0.5,
1579                    ) {
1580                        Ok(summary) => {
1581                            let arr: Vec<Value> = summary
1582                                .sentences
1583                                .iter()
1584                                .zip(summary.scores.iter())
1585                                .map(|(s, score)| json!({"sentence": s, "score": score}))
1586                                .collect();
1587                            response["summary"] = Value::Array(arr);
1588                        }
1589                        Err(e) => {
1590                            response["summary"] = json!([]);
1591                            response["summarize_skipped"] = json!(format!("summarize failed: {e}"));
1592                        }
1593                    }
1594                }
1595                Err(e) => {
1596                    response["summary"] = json!([]);
1597                    response["summarize_skipped"] =
1598                        json!(format!("embed provider open failed: {e}"));
1599                }
1600            }
1601        } else {
1602            response["summary"] = json!([]);
1603            response["summarize_skipped"] = json!("no [embed] provider configured on server");
1604        }
1605    }
1606
1607    Ok(Json(response))
1608}
1609
1610/// Parse a PROVIDER:MODEL rerank spec into a live
1611/// `mnem_rerank_providers::ProviderConfig`. Reads API-key env-var
1612/// names from the defaults shipped by mnem-rerank-providers; callers
1613/// who need custom env vars must set them via the `[rerank]` section
1614/// in `config.toml` and rely on the CLI instead.
1615fn parse_rerank_spec(spec: &str) -> Result<mnem_rerank_providers::ProviderConfig, String> {
1616    let (prov, model) = spec
1617        .split_once(':')
1618        .ok_or_else(|| format!("expected PROVIDER:MODEL, got `{spec}`"))?;
1619    if model.is_empty() {
1620        return Err(format!("empty model in `{spec}`"));
1621    }
1622    match prov {
1623        "cohere" => Ok(mnem_rerank_providers::ProviderConfig::Cohere(
1624            mnem_rerank_providers::CohereConfig {
1625                model: model.into(),
1626                ..Default::default()
1627            },
1628        )),
1629        "voyage" => Ok(mnem_rerank_providers::ProviderConfig::Voyage(
1630            mnem_rerank_providers::VoyageConfig {
1631                model: model.into(),
1632                ..Default::default()
1633            },
1634        )),
1635        "jina" => Ok(mnem_rerank_providers::ProviderConfig::Jina(
1636            mnem_rerank_providers::JinaConfig {
1637                model: model.into(),
1638                ..Default::default()
1639            },
1640        )),
1641        other => Err(format!(
1642            "unknown rerank provider `{other}`; want cohere|voyage|jina"
1643        )),
1644    }
1645}
1646
1647// ---------- helpers ----------
1648//
1649// `json_to_ipld` is re-exported from `mnem_core::codec`; keeping one
1650// canonical implementation in the core crate ensures that any future
1651// hardening (depth cap adjustment, additional numeric rejection, ...)
1652// applies uniformly across CLI, HTTP, and MCP inputs. See
1653// `crates/mnem-core/src/codec/json.rs` for the shared logic.
1654
1655fn ipld_to_json(v: &Ipld) -> Value {
1656    match v {
1657        Ipld::Null => Value::Null,
1658        Ipld::Bool(b) => Value::Bool(*b),
1659        Ipld::Integer(i) => serde_json::Number::from_i128(*i).map_or(Value::Null, Value::Number),
1660        Ipld::Float(f) => serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number),
1661        Ipld::String(s) => Value::String(s.clone()),
1662        Ipld::Bytes(b) => Value::String(format!("<{} bytes>", b.len())),
1663        Ipld::List(xs) => Value::Array(xs.iter().map(ipld_to_json).collect()),
1664        Ipld::Map(m) => {
1665            let mut out = Map::new();
1666            for (k, v) in m {
1667                out.insert(k.clone(), ipld_to_json(v));
1668            }
1669            Value::Object(out)
1670        }
1671        Ipld::Link(cid) => Value::String(cid.to_string()),
1672    }
1673}
1674
1675fn parse_kv(s: &str) -> Result<(String, Ipld), String> {
1676    let (k, v) = s
1677        .split_once('=')
1678        .ok_or_else(|| format!("expected KEY=VALUE, got `{s}`"))?;
1679    let val = match serde_json::from_str::<Value>(v) {
1680        Ok(json) => json_to_ipld(&json).map_err(|e| e.to_string())?,
1681        Err(_) => Ipld::String(v.to_string()),
1682    };
1683    Ok((k.to_string(), val))
1684}
1685
1686// ============================================================
1687// Gap 01 (agent-hop incentive) helpers.
1688//
1689// All three helpers below are pure functions of the ranked items;
1690// they do not touch the repo, do not allocate index structures,
1691// and do not emit metrics on their own (the caller does, in
1692// `retrieve_full`).
1693//
1694// They are `pub(crate)` so the integration / proptest module
1695// (`tests::gap01_neighbors_proptest`) can exercise them without
1696// spinning up a full `AppState`.
1697// ============================================================
1698
1699/// How many top-ranked items to treat as "seeds" when slicing the
1700/// neighbour list. Matches the rest of the Gap 01 spec's
1701/// `community_expand_seeds` default and the `max_neighbours = 3`
1702/// floor-c constant pinned in
1703/// `gap-catalog/01-agent-hop-incentive/solution.md`.
1704pub(crate) const GAP01_TOP_SEEDS: usize = 3;
1705
1706/// Per-request cap on the number of neighbour hints emitted.
1707/// Floor-c constant: per-item amplification bound from
1708/// `SPEC §retrieve.response-budget` (aggregate response bytes
1709/// <= 64 KiB). See `gap-catalog/01-agent-hop-incentive/solution.md`
1710/// "Floor-c apparatus".
1711pub(crate) const GAP01_MAX_NEIGHBOURS: usize = 3;
1712
1713/// Clip length for the neighbour `preview` field, in chars.
1714/// Bounds the response-size contribution of the hints block;
1715/// the value is the HTTP per-line budget used elsewhere in this
1716/// crate.
1717pub(crate) const GAP01_PREVIEW_CHARS: usize = 200;
1718
1719/// Compute `confidence` as rank-agreement derived from the score
1720/// distribution of `items`.
1721///
1722/// `confidence = 1 - S(k) / S(1)` where `S(i)` is the i-th
1723/// sorted score (descending). Captures "is the top item clearly
1724/// ahead of the pack?" without a magic threshold. Scale-free
1725/// because both the numerator and denominator are drawn from
1726/// the same score distribution.
1727///
1728/// Returns `0.0` on degenerate input (`< 2` items, non-positive
1729/// top score, NaN top score).
1730pub(crate) fn gap01_compute_confidence(items: &[mnem_core::retrieve::RetrievedItem]) -> f32 {
1731    if items.len() < 2 {
1732        return 0.0;
1733    }
1734    let top = items[0].score;
1735    if !top.is_finite() || top <= 0.0 {
1736        return 0.0;
1737    }
1738    // `items` is already in RRF-rank order (descending score), but
1739    // defend against a degenerate case where ties re-order past
1740    // the caller's expectation by taking the raw last element.
1741    let tail = items[items.len() - 1].score.max(0.0);
1742    (1.0 - (tail / top)).clamp(0.0, 1.0)
1743}
1744
1745/// Compute the `suggested_neighbors` list (up to
1746/// [`GAP01_MAX_NEIGHBOURS`] entries) from the ranked items past
1747/// the top [`GAP01_TOP_SEEDS`] seeds.
1748///
1749/// Each entry is `{id, preview, via}`. `via` is always
1750/// `"adjacency"` because neighbours are drawn from the same
1751/// adjacency-derived ranked list; if a future Gap 15 integration
1752/// sources neighbours from KNN substrate, the `via` label flips
1753/// to `"knn"`.
1754///
1755/// Guaranteed a subset of `items` by construction. The proptest
1756/// `suggested_neighbors_always_subset_of_adjacency` pins this
1757/// invariant across random inputs.
1758pub(crate) fn gap01_suggested_neighbors(
1759    items: &[mnem_core::retrieve::RetrievedItem],
1760) -> Vec<Value> {
1761    items
1762        .iter()
1763        .skip(GAP01_TOP_SEEDS)
1764        .take(GAP01_MAX_NEIGHBOURS)
1765        .map(|it| {
1766            let preview: String = it.rendered.chars().take(GAP01_PREVIEW_CHARS).collect();
1767            json!({
1768            "id": it.node.id.to_uuid_string(),
1769            "preview": preview,
1770            "via": "adjacency",
1771            })
1772        })
1773        .collect()
1774}
1775
1776// ---------- POST /v1/explain (gap-06) ----------
1777
1778/// Default serialisation throughput in bytes/ms used to derive
1779/// `max_path_bytes_total` when the caller omits `latency_budget_ms`.
1780pub(crate) const DEFAULT_SERIALIZATION_RATE_BYTES_PER_MS: u64 = 4_096;
1781
1782/// Default per-request latency budget in milliseconds.
1783pub(crate) const DEFAULT_LATENCY_BUDGET_MS: u32 = 256;
1784
1785/// Max per-node incoming fan-in walked during BFS. Matches
1786/// `Query::DEFAULT_ADJACENCY_CAP` and prevents a celebrity dst DoS.
1787pub(crate) const EXPLAIN_ADJACENCY_CAP: usize = 256;
1788
1789/// Max BFS depth the `/v1/explain` handler will honour regardless
1790/// of the request. `u16` for parent-index compactness.
1791pub(crate) const EXPLAIN_MAX_DEPTH: u16 = 8;
1792
1793/// `explain_mode` enum (Round 3 of gap-06).
1794#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
1795#[serde(rename_all = "snake_case")]
1796pub(crate) enum ExplainMode {
1797    /// Compact parent-pointer encoding, IDs only. Multi-tenant safe.
1798    #[default]
1799    Compact,
1800    /// Compact + full payloads. Requires ACL; falls back to
1801    /// `Compact` with a warning when requested without ACL.
1802    CompactFull,
1803}
1804
1805/// Request body for `POST /v1/explain`.
1806#[derive(Deserialize, Debug)]
1807pub(crate) struct ExplainRequest {
1808    /// Seed node. BFS fans outward along incoming edges.
1809    pub node_id: String,
1810    /// Max depth; clamped to [`EXPLAIN_MAX_DEPTH`].
1811    #[serde(default = "default_explain_depth")]
1812    pub depth: u16,
1813    /// Encoding mode. Default [`ExplainMode::Compact`].
1814    #[serde(default)]
1815    pub mode: ExplainMode,
1816    /// Per-request latency budget in ms.
1817    #[serde(default)]
1818    pub latency_budget_ms: Option<u32>,
1819    /// Serialisation throughput override.
1820    #[serde(default)]
1821    pub serialization_rate_bytes_per_ms: Option<u64>,
1822}
1823
1824fn default_explain_depth() -> u16 {
1825    3
1826}
1827
1828/// Runtime derivation: `max_path_bytes_total = remaining_ms *
1829/// serialization_rate_bytes_per_ms`, saturating on overflow.
1830///
1831/// Exposed at the crate root (see `lib.rs`) so integration tests
1832/// can exercise the invariant directly.
1833#[must_use]
1834pub fn derive_max_path_bytes(remaining_ms: u32, serialization_rate_bytes_per_ms: u64) -> usize {
1835    u64::from(remaining_ms)
1836        .saturating_mul(serialization_rate_bytes_per_ms)
1837        .try_into()
1838        .unwrap_or(usize::MAX)
1839}
1840
1841/// `POST /v1/explain`: in-band derivation path via BFS over the
1842/// incoming-edge adjacency index. Redacts to IDs only by default.
1843pub(crate) async fn explain(
1844    State(s): State<AppState>,
1845    Json(body): Json<ExplainRequest>,
1846) -> Result<Json<Value>, Error> {
1847    let seed = NodeId::parse_uuid(&body.node_id)
1848        .map_err(|e| Error::bad_request(format!("invalid node_id UUID: {e}")))?;
1849    let depth = body.depth.min(EXPLAIN_MAX_DEPTH);
1850
1851    // Runtime-derived byte cap. No magic number: caller can override
1852    // both knobs. `.filter(|&v| v > 0)` keeps zero from silently
1853    // disabling the cap.
1854    let rate = body
1855        .serialization_rate_bytes_per_ms
1856        .filter(|&r| r > 0)
1857        .unwrap_or(DEFAULT_SERIALIZATION_RATE_BYTES_PER_MS);
1858    let budget_ms = body
1859        .latency_budget_ms
1860        .filter(|&m| m > 0)
1861        .unwrap_or(DEFAULT_LATENCY_BUDGET_MS);
1862    let max_bytes = derive_max_path_bytes(budget_ms, rate);
1863
1864    // ACL gate: compact_full requires per-tenant ACL (not in a future version).
1865    let (effective_mode, mode_warning): (ExplainMode, Option<&'static str>) = match body.mode {
1866        ExplainMode::Compact => (ExplainMode::Compact, None),
1867        ExplainMode::CompactFull => (
1868            ExplainMode::Compact,
1869            Some("compact_full requested but no ACL is configured; falling back to compact"),
1870        ),
1871    };
1872
1873    let repo = s.repo.lock().map_err(|_| Error::locked())?;
1874
1875    // BFS with parent tracking. `nodes[0]` is the seed; every step
1876    // carries `(parent_idx, to_idx)` into the nodes array.
1877    let mut nodes: Vec<NodeId> = vec![seed];
1878    let mut visited: std::collections::HashMap<NodeId, u32> = std::collections::HashMap::new();
1879    visited.insert(seed, 0);
1880    let mut steps: Vec<(u16, u32)> = Vec::new();
1881    let mut truncated_reason: Option<&'static str> = None;
1882
1883    let mut frontier: Vec<u32> = vec![0];
1884    'bfs: for _hop in 0..depth {
1885        let mut next_frontier: Vec<u32> = Vec::new();
1886        for &parent_idx in &frontier {
1887            let parent_node = nodes[parent_idx as usize];
1888            let edges = repo
1889                .incoming_edges_capped(&parent_node, None, EXPLAIN_ADJACENCY_CAP)
1890                .map_err(Error::from)?;
1891            for edge in edges {
1892                let from = edge.src;
1893                if visited.contains_key(&from) {
1894                    continue;
1895                }
1896                // Projected wire bytes: ~32/step + ~40/node (JSON).
1897                let projected =
1898                    steps.len().saturating_mul(32) + nodes.len().saturating_mul(40) + 32;
1899                if projected > max_bytes {
1900                    truncated_reason = Some("response_budget");
1901                    break 'bfs;
1902                }
1903                let new_idx: u32 = nodes.len().try_into().unwrap_or(u32::MAX);
1904                nodes.push(from);
1905                visited.insert(from, new_idx);
1906                steps.push((u16::try_from(parent_idx).unwrap_or(u16::MAX), new_idx));
1907                next_frontier.push(new_idx);
1908            }
1909        }
1910        if next_frontier.is_empty() {
1911            break;
1912        }
1913        frontier = next_frontier;
1914    }
1915    if truncated_reason.is_none() && depth == EXPLAIN_MAX_DEPTH && !frontier.is_empty() {
1916        truncated_reason = Some("depth");
1917    }
1918    drop(repo);
1919
1920    let nodes_wire: Vec<Value> = nodes
1921        .iter()
1922        .map(|n| Value::String(n.to_uuid_string()))
1923        .collect();
1924    let steps_wire: Vec<Value> = steps
1925        .iter()
1926        .map(|(p, t)| {
1927            json!({
1928            "parent_idx": p,
1929            "to_idx": t,
1930            })
1931        })
1932        .collect();
1933
1934    let mut warnings: Vec<Value> = Vec::new();
1935    if let Some(w) = mode_warning {
1936        warnings.push(json!({
1937        "code": "explain.mode_downgraded",
1938        "message": w,
1939        }));
1940    }
1941
1942    let mode_str = match effective_mode {
1943        ExplainMode::Compact => "compact",
1944        ExplainMode::CompactFull => "compact_full",
1945    };
1946
1947    Ok(Json(json!({
1948    "schema": "mnem.v1.explain",
1949    "seed": seed.to_uuid_string(),
1950    "mode": mode_str,
1951    "path_source":
1952    format!("bfs.v1:graph_depth={depth}:edge_source=adjacency.v1"),
1953    "max_path_bytes_total": max_bytes,
1954    "latency_budget_ms": budget_ms,
1955    "serialization_rate_bytes_per_ms": rate,
1956    "nodes": nodes_wire,
1957    "steps": steps_wire,
1958    "path_truncated": truncated_reason.is_some(),
1959    "path_truncated_reason": truncated_reason,
1960    "warnings": warnings,
1961    })))
1962}
1963
1964// Proptest for `byte_cap_never_exceeds_budget` lives in
1965// `tests/wire_explain.rs` so it runs under the integration harness
1966// (avoids a dependency on the pre-existing `gap01_tests` module
1967// whose `Node::new` call was broken by an upstream signature
1968// change). Callers verifying the invariant can reuse the
1969// `pub(crate)` `derive_max_path_bytes` function exposed above.
1970
1971// ---------- GET /v1/log ----------
1972
1973/// Maximum number of log entries returnable in a single request.
1974pub(crate) const MAX_LOG_LIMIT: usize = 500;
1975
1976/// Default number of log entries returned when `limit` is not specified.
1977fn default_log_limit() -> usize {
1978    50
1979}
1980
1981/// Output format for `GET /v1/log`.
1982#[derive(serde::Deserialize, Default, Clone, Copy, Debug)]
1983#[serde(rename_all = "lowercase")]
1984pub(crate) enum LogFormat {
1985    /// Structured JSON array (default). Returns `application/json`.
1986    #[default]
1987    Json,
1988    /// One line per op: `<short-cid> <description>`. Returns `text/plain`.
1989    Oneline,
1990    /// Multi-line human-readable (mirrors `mnem log` default). Returns `text/plain`.
1991    Full,
1992}
1993
1994/// Query parameters for `GET /v1/log`.
1995#[derive(serde::Deserialize)]
1996pub(crate) struct LogParams {
1997    /// Maximum number of entries to return (default 50, max 500).
1998    #[serde(default = "default_log_limit")]
1999    pub limit: usize,
2000    /// Output format: `json` (default), `oneline`, or `full`.
2001    #[serde(default)]
2002    pub format: LogFormat,
2003}
2004
2005/// One entry in the JSON log response.
2006#[derive(serde::Serialize)]
2007struct LogEntry {
2008    op_id: String,
2009    timestamp: String,
2010    author: String,
2011    message: String,
2012    parents: Vec<String>,
2013    #[serde(skip_serializing_if = "Option::is_none")]
2014    agent_id: Option<String>,
2015    #[serde(skip_serializing_if = "Option::is_none")]
2016    task_id: Option<String>,
2017}
2018
2019/// Produce a short-hex prefix of a CID for `oneline` output.
2020/// Mirrors the CLI's `short_cid` helper: skip 2 bytes, take 8.
2021fn short_cid_str(full: &str) -> String {
2022    if full.len() <= 10 {
2023        full.to_string()
2024    } else {
2025        full.chars().skip(2).take(8).collect()
2026    }
2027}
2028
2029/// Convert microseconds-since-epoch to an RFC 3339 timestamp string.
2030/// Falls back to the raw integer (as a string) on overflow.
2031fn micros_to_rfc3339(micros: u64) -> String {
2032    use std::time::{Duration, UNIX_EPOCH};
2033    let secs = micros / 1_000_000;
2034    let nanos = ((micros % 1_000_000) * 1_000) as u32;
2035    match UNIX_EPOCH.checked_add(Duration::new(secs, nanos)) {
2036        Some(_t) => {
2037            // Format as RFC 3339 UTC without pulling in `chrono` or `time`.
2038            // The SystemTime Display is not RFC 3339 so we build it manually.
2039            let total_secs = secs;
2040            let s = total_secs % 60;
2041            let m = (total_secs / 60) % 60;
2042            let h = (total_secs / 3600) % 24;
2043            let days = total_secs / 86400;
2044            // Gregorian calendar: days since 1970-01-01
2045            let (year, month, day) = days_to_ymd(days);
2046            format!(
2047                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
2048                year,
2049                month,
2050                day,
2051                h,
2052                m,
2053                s,
2054                micros % 1_000_000,
2055            )
2056        }
2057        None => micros.to_string(),
2058    }
2059}
2060
2061/// Convert days since Unix epoch to (year, month, day).
2062/// Implements the standard proleptic Gregorian calendar algorithm.
2063fn days_to_ymd(days: u64) -> (u64, u8, u8) {
2064    // Algorithm from https://howardhinnant.github.io/date_algorithms.html
2065    // (civil_from_days, public domain). Adapted for unsigned input.
2066    let z = days as i64 + 719_468;
2067    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2068    let doe = z - era * 146_097;
2069    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
2070    let y = yoe + era * 400;
2071    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2072    let mp = (5 * doy + 2) / 153;
2073    let d = doy - (153 * mp + 2) / 5 + 1;
2074    let m = if mp < 10 { mp + 3 } else { mp - 9 };
2075    let y = if m <= 2 { y + 1 } else { y };
2076    (y as u64, m as u8, d as u8)
2077}
2078
2079/// Read and decode one Operation from the blockstore, returning the decoded op
2080/// and the next CID to follow (the first parent), or `None` if this is the root.
2081fn read_op(
2082    bs: &dyn mnem_core::store::Blockstore,
2083    cid: &mnem_core::id::Cid,
2084) -> Result<(Operation, Option<mnem_core::id::Cid>), Error> {
2085    let bytes = bs
2086        .get(cid)
2087        .map_err(|e| Error::internal(format!("blockstore read: {e}")))?
2088        .ok_or_else(|| Error::internal(format!("op {cid} missing from store")))?;
2089    let op: Operation = from_canonical_bytes(&bytes)
2090        .map_err(|e| Error::internal(format!("decode op {cid}: {e}")))?;
2091    let next = op.parents.first().cloned();
2092    Ok((op, next))
2093}
2094
2095/// `GET /v1/log` - walk the op-log backwards from the current head.
2096///
2097/// Query params:
2098/// - `limit`: max entries to return (default 50, max 500)
2099/// - `format`: `json` (default) | `oneline` | `full`
2100///
2101/// JSON response: `{ "schema": "mnem.v1.log", "entries": [...], "count": N }`
2102/// Text responses (oneline/full): `text/plain; charset=utf-8`
2103pub(crate) async fn get_log(
2104    State(s): State<AppState>,
2105    Query(params): Query<LogParams>,
2106) -> Result<impl IntoResponse, Error> {
2107    // Clamp limit at the hard cap so callers cannot request unbounded work.
2108    let limit = params.limit.min(MAX_LOG_LIMIT);
2109    if limit == 0 {
2110        return Err(Error::bad_request("limit must be >= 1"));
2111    }
2112
2113    let repo = s.repo.lock().map_err(|_| Error::locked())?;
2114    let bs = repo.blockstore().clone();
2115    let mut cur = repo.op_id().clone();
2116
2117    match params.format {
2118        LogFormat::Json => {
2119            let mut entries: Vec<LogEntry> = Vec::with_capacity(limit);
2120            for _ in 0..limit {
2121                let (op, next) = read_op(bs.as_ref(), &cur)?;
2122                entries.push(LogEntry {
2123                    op_id: cur.to_string(),
2124                    timestamp: micros_to_rfc3339(op.time),
2125                    author: op.author.clone(),
2126                    message: op.description.clone(),
2127                    parents: op.parents.iter().map(ToString::to_string).collect(),
2128                    agent_id: op.agent_id.clone(),
2129                    task_id: op.task_id.clone(),
2130                });
2131                match next {
2132                    Some(p) => cur = p,
2133                    None => break,
2134                }
2135            }
2136            let count = entries.len();
2137            Ok(Json(serde_json::json!({
2138                "schema": "mnem.v1.log",
2139                "entries": entries,
2140                "count": count,
2141            }))
2142            .into_response())
2143        }
2144
2145        LogFormat::Oneline => {
2146            let mut lines = String::new();
2147            for _ in 0..limit {
2148                let short = short_cid_str(&cur.to_string());
2149                let (op, next) = read_op(bs.as_ref(), &cur)?;
2150                lines.push_str(&format!("{short} {}\n", op.description));
2151                match next {
2152                    Some(p) => cur = p,
2153                    None => break,
2154                }
2155            }
2156            Ok(([(CONTENT_TYPE, "text/plain; charset=utf-8")], lines).into_response())
2157        }
2158
2159        LogFormat::Full => {
2160            let mut text = String::new();
2161            for _ in 0..limit {
2162                let op_id_str = cur.to_string();
2163                let (op, next) = read_op(bs.as_ref(), &cur)?;
2164                text.push_str(&format!("op {op_id_str}\n"));
2165                text.push_str(&format!("   time    {}us\n", op.time));
2166                if !op.author.is_empty() {
2167                    text.push_str(&format!("   author  {}\n", op.author));
2168                }
2169                if let Some(agent) = &op.agent_id {
2170                    text.push_str(&format!("   agent   {agent}\n"));
2171                }
2172                if let Some(task) = &op.task_id {
2173                    text.push_str(&format!("   task    {task}\n"));
2174                }
2175                text.push_str(&format!("   message {}\n", op.description));
2176                text.push('\n');
2177                match next {
2178                    Some(p) => cur = p,
2179                    None => break,
2180                }
2181            }
2182            Ok(([(CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response())
2183        }
2184    }
2185}
2186
2187// ---------- GET /v1/export ----------
2188
2189/// Hard cap on operations walked during export. Prevents runaway
2190/// work on very long op-logs requested without a `limit` parameter.
2191const MAX_EXPORT_OPS: usize = 10_000;
2192
2193/// Query parameters for `GET /v1/export`.
2194#[derive(serde::Deserialize)]
2195pub(crate) struct ExportParams {
2196    /// Maximum number of ops to export (default: all, hard cap 10,000).
2197    #[serde(default)]
2198    pub limit: Option<usize>,
2199}
2200
2201/// `GET /v1/export` - export all reachable blocks as NDJSON.
2202///
2203/// Walks the op-log from HEAD backwards, collects all reachable blocks
2204/// (deduplicated via `Blockstore::iter_from_root` on each op CID), and
2205/// streams them as newline-delimited JSON. Each line:
2206///
2207/// ```json
2208/// {"cid":"<cid-string>","hex":"<hex-encoded-bytes>"}
2209/// ```
2210///
2211/// Query params:
2212/// - `limit` - max ops to export (default: all reachable, hard cap 10,000)
2213///
2214/// Response: `application/x-ndjson`
2215pub(crate) async fn get_export(
2216    State(s): State<AppState>,
2217    Query(params): Query<ExportParams>,
2218) -> Result<impl IntoResponse, Error> {
2219    let limit = params.limit.unwrap_or(MAX_EXPORT_OPS).min(MAX_EXPORT_OPS);
2220
2221    let repo = s.repo.lock().map_err(|_| Error::locked())?;
2222    let bs = repo.blockstore().clone();
2223    let mut cur_op = repo.op_id().clone();
2224    drop(repo); // Release the lock before doing potentially expensive block reads.
2225
2226    // Walk the op-log, collecting all block CIDs reachable from each op.
2227    // `iter_from_root` does dedup within a single root; we dedup across ops
2228    // with a visited set.
2229    let mut seen: std::collections::HashSet<mnem_core::id::Cid> = std::collections::HashSet::new();
2230    let mut blocks: Vec<(mnem_core::id::Cid, bytes::Bytes)> = Vec::new();
2231    let mut ops_walked = 0usize;
2232
2233    loop {
2234        if ops_walked >= limit {
2235            break;
2236        }
2237        ops_walked += 1;
2238
2239        // Read the op block directly (do NOT use iter_from_root on the op CID,
2240        // because the op's DAG-CBOR encoding embeds `parents` as IPLD links and
2241        // iter_from_root would recursively follow them into all ancestor ops,
2242        // making the limit ineffective).
2243        let op_bytes = bs
2244            .get(&cur_op)
2245            .map_err(|e| Error::internal(format!("blockstore read: {e}")))?
2246            .ok_or_else(|| Error::internal(format!("op {cur_op} missing from store")))?;
2247        let op: Operation = from_canonical_bytes(&op_bytes)
2248            .map_err(|e| Error::internal(format!("decode op {cur_op}: {e}")))?;
2249
2250        // Add the op block itself.
2251        if seen.insert(cur_op.clone()) {
2252            blocks.push((cur_op.clone(), op_bytes));
2253        }
2254
2255        // Walk the view sub-DAG (commit block + prolly tree blocks +
2256        // node/edge/embedding blocks). The view CID has no parent-op links,
2257        // so iter_from_root stays within this op's payload.
2258        for result in bs.iter_from_root(&op.view) {
2259            let (cid, data) =
2260                result.map_err(|e| Error::internal(format!("blockstore walk: {e}")))?;
2261            if seen.insert(cid.clone()) {
2262                blocks.push((cid, data));
2263            }
2264        }
2265
2266        // Advance to the first parent op.
2267        match op.parents.first() {
2268            Some(parent) => cur_op = parent.clone(),
2269            None => break, // Root op reached.
2270        }
2271    }
2272
2273    // Serialize as NDJSON. Each line: {"cid":"<cid>","hex":"<hex>"}
2274    let mut ndjson = String::new();
2275    for (cid, data) in &blocks {
2276        // Hex-encode block bytes (no base64 dep needed).
2277        let hex: String = data.iter().map(|b| format!("{b:02x}")).collect();
2278        ndjson.push_str(&format!("{{\"cid\":\"{cid}\",\"hex\":\"{hex}\"}}\n",));
2279    }
2280
2281    Ok(([(CONTENT_TYPE, "application/x-ndjson")], ndjson).into_response())
2282}
2283
2284// ---------- POST /v1/import ----------
2285
2286/// Request body for `POST /v1/import`.
2287///
2288/// Expects `application/x-ndjson` with one block per line in the format
2289/// produced by `GET /v1/export`:
2290/// ```json
2291/// {"cid":"<cid-string>","hex":"<hex-encoded-bytes>"}
2292/// ```
2293///
2294/// `POST /v1/import` - import blocks from NDJSON stream.
2295///
2296/// Reads each line, decodes the hex bytes, verifies the CID, and writes
2297/// the block to the blockstore. Does NOT advance HEAD - this is a
2298/// block-level sync primitive only.
2299///
2300/// Response JSON:
2301/// ```json
2302/// {"imported": N, "errors": [...], "ok": true}
2303/// ```
2304pub(crate) async fn post_import(
2305    State(s): State<AppState>,
2306    headers: axum::http::HeaderMap,
2307    body: axum::body::Bytes,
2308) -> Result<Json<Value>, Error> {
2309    use mnem_core::store::blockstore::recompute_cid;
2310
2311    // Reject non-NDJSON/text Content-Types when the header is present.
2312    // Absent header is accepted (lenient, matches curl --data-binary behavior).
2313    if let Some(ct) = headers.get(axum::http::header::CONTENT_TYPE) {
2314        let ct_str = ct.to_str().unwrap_or("").trim();
2315        // Strip parameters (e.g. "; charset=utf-8") before comparing.
2316        let ct_base = ct_str.split(';').next().unwrap_or("").trim();
2317        if ct_base != "application/x-ndjson" && ct_base != "text/plain" {
2318            return Err(Error::status(
2319                axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2320                format!(
2321                    "unsupported Content-Type '{ct_base}'; expected application/x-ndjson or text/plain"
2322                ),
2323            ));
2324        }
2325    }
2326
2327    let text = std::str::from_utf8(&body)
2328        .map_err(|e| Error::bad_request(format!("request body is not valid UTF-8: {e}")))?;
2329
2330    let repo = s.repo.lock().map_err(|_| Error::locked())?;
2331    let bs = repo.blockstore().clone();
2332    drop(repo);
2333
2334    let mut imported: usize = 0;
2335    let mut errors: Vec<Value> = Vec::new();
2336
2337    for (line_no, line) in text.lines().enumerate() {
2338        let line = line.trim();
2339        if line.is_empty() {
2340            continue;
2341        }
2342
2343        // Parse the JSON line.
2344        let obj: Value = match serde_json::from_str(line) {
2345            Ok(v) => v,
2346            Err(e) => {
2347                errors.push(json!({
2348                    "line": line_no + 1,
2349                    "error": format!("JSON parse error: {e}"),
2350                }));
2351                continue;
2352            }
2353        };
2354
2355        let cid_str = match obj.get("cid").and_then(Value::as_str) {
2356            Some(s) => s,
2357            None => {
2358                errors.push(json!({
2359                    "line": line_no + 1,
2360                    "error": "missing or non-string \"cid\" field",
2361                }));
2362                continue;
2363            }
2364        };
2365
2366        let hex_str = match obj.get("hex").and_then(Value::as_str) {
2367            Some(s) => s,
2368            None => {
2369                errors.push(json!({
2370                    "line": line_no + 1,
2371                    "error": "missing or non-string \"hex\" field",
2372                }));
2373                continue;
2374            }
2375        };
2376
2377        // Parse the CID from its multibase string representation.
2378        let claimed_cid = match mnem_core::id::Cid::parse_str(cid_str) {
2379            Ok(c) => c,
2380            Err(e) => {
2381                errors.push(json!({
2382                    "line": line_no + 1,
2383                    "cid": cid_str,
2384                    "error": format!("invalid CID: {e}"),
2385                }));
2386                continue;
2387            }
2388        };
2389
2390        // Decode hex bytes.
2391        if hex_str.len() % 2 != 0 {
2392            errors.push(json!({
2393                "line": line_no + 1,
2394                "cid": cid_str,
2395                "error": "hex string has odd length",
2396            }));
2397            continue;
2398        }
2399        let mut raw: Vec<u8> = Vec::with_capacity(hex_str.len() / 2);
2400        let mut parse_ok = true;
2401        for chunk in hex_str.as_bytes().chunks(2) {
2402            let hi = (chunk[0] as char).to_digit(16);
2403            let lo = (chunk[1] as char).to_digit(16);
2404            match (hi, lo) {
2405                (Some(h), Some(l)) => raw.push((h * 16 + l) as u8),
2406                _ => {
2407                    errors.push(json!({
2408                        "line": line_no + 1,
2409                        "cid": cid_str,
2410                        "error": "invalid hex character",
2411                    }));
2412                    parse_ok = false;
2413                    break;
2414                }
2415            }
2416        }
2417        if !parse_ok {
2418            continue;
2419        }
2420
2421        let data = bytes::Bytes::from(raw);
2422
2423        // CID verification: recompute and compare before writing.
2424        // `recompute_cid` returns `None` for unknown hash algorithms;
2425        // in that case we trust the claim (same policy as `Blockstore::put`).
2426        if let Some(computed) = recompute_cid(&claimed_cid, &data) {
2427            if computed != claimed_cid {
2428                errors.push(json!({
2429                    "line": line_no + 1,
2430                    "cid": cid_str,
2431                    "error": format!("CID mismatch: claimed {claimed_cid} but data hashes to {computed}"),
2432                }));
2433                continue;
2434            }
2435        }
2436
2437        // Write to blockstore. `put` is idempotent for already-present blocks.
2438        match bs.put(claimed_cid, data) {
2439            Ok(()) => imported += 1,
2440            Err(e) => {
2441                errors.push(json!({
2442                    "line": line_no + 1,
2443                    "cid": cid_str,
2444                    "error": format!("blockstore write: {e}"),
2445                }));
2446            }
2447        }
2448    }
2449
2450    let ok = errors.is_empty();
2451    Ok(Json(json!({
2452        "schema": "mnem.v1.import",
2453        "imported": imported,
2454        "errors": errors,
2455        "ok": ok,
2456    })))
2457}
2458
2459// ---------- GET /v1/branches ----------
2460
2461/// `GET /v1/branches` - list all branches.
2462///
2463/// Returns every ref whose name begins with `refs/heads/`, annotating
2464/// each with its target commit CID and whether it points at the current
2465/// head commit (`is_current`).
2466///
2467/// Response schema: `mnem.v1.branches`
2468/// ```json
2469/// {"branches": [{"name": "main", "head": "<commit-cid>", "is_current": true}, ...]}
2470/// ```
2471pub(crate) async fn get_branches(State(s): State<AppState>) -> Result<Json<Value>, Error> {
2472    let repo = s.repo.lock().map_err(|_| Error::locked())?;
2473    let view = repo.view();
2474    let current_head = view.heads.first().cloned();
2475
2476    let branches: Vec<Value> = view
2477        .refs
2478        .iter()
2479        .filter_map(|(name, target)| {
2480            let short = name.strip_prefix(HEADS_PREFIX)?;
2481            let (head_str, is_current) = match target {
2482                mnem_core::objects::RefTarget::Normal { target } => {
2483                    let is_cur = Some(target) == current_head.as_ref();
2484                    (target.to_string(), is_cur)
2485                }
2486                mnem_core::objects::RefTarget::Conflicted { .. } => {
2487                    // Expose conflicted refs with an empty head so callers
2488                    // can see they exist without crashing the listing.
2489                    (String::new(), false)
2490                }
2491            };
2492            Some(json!({
2493                "name": short,
2494                "head": head_str,
2495                "is_current": is_current,
2496            }))
2497        })
2498        .collect();
2499
2500    Ok(Json(json!({
2501        "schema": "mnem.v1.branches",
2502        "branches": branches,
2503    })))
2504}
2505
2506// ---------- POST /v1/branches ----------
2507
2508/// Request body for `POST /v1/branches`.
2509#[derive(Deserialize)]
2510pub(crate) struct CreateBranchBody {
2511    /// Short branch name (e.g. `"feature-x"`). Stored as
2512    /// `refs/heads/<name>` in the View.
2513    pub name: String,
2514    /// Optional commit CID to point the new branch at. When absent,
2515    /// defaults to the current head commit.
2516    #[serde(default)]
2517    pub at: Option<String>,
2518    /// Commit author recorded on the `update_ref` Operation.
2519    pub author: String,
2520}
2521
2522/// `POST /v1/branches` - create a new branch.
2523///
2524/// Creates `refs/heads/<name>` pointing at `at` (or HEAD when absent).
2525/// Fails 400 if the name already exists or the repo has no commits yet.
2526///
2527/// Response schema: `mnem.v1.branch-create`
2528/// ```json
2529/// {"name": "feature-x", "head": "<commit-cid>", "created": true}
2530/// ```
2531pub(crate) async fn post_branch(
2532    State(s): State<AppState>,
2533    Json(body): Json<CreateBranchBody>,
2534) -> Result<Json<Value>, Error> {
2535    if body.name.trim().is_empty() {
2536        return Err(Error::bad_request("name is required"));
2537    }
2538    if body.name.len() > 255 {
2539        return Err(Error::bad_request(
2540            "branch name exceeds maximum length of 255 characters",
2541        ));
2542    }
2543    if body.author.trim().is_empty() {
2544        return Err(Error::bad_request("author is required"));
2545    }
2546    // Basic refname sanity: reject characters that break VCS tooling.
2547    let n = &body.name;
2548    if n.contains(' ')
2549        || n.contains('\t')
2550        || n.contains('\n')
2551        || n.contains('\x00')
2552        || n.contains('~')
2553        || n.contains('^')
2554        || n.contains(':')
2555        || n.contains('?')
2556        || n.contains('*')
2557        || n.contains('[')
2558        || n.contains('\\')
2559        || n.contains("@{")
2560        || n.contains("..")
2561        || n.contains("//")
2562        || n.starts_with('/')
2563        || n.ends_with('/')
2564        || n.ends_with('.')
2565        || n.ends_with(".lock")
2566    {
2567        return Err(Error::bad_request(format!(
2568            "invalid branch name `{n}`: may not contain spaces, control characters, \
2569             `~`, `^`, `:`, `?`, `*`, `[`, `\\`, `@{{`, `..`, `//`, \
2570             or start/end with `/`, or end with `.` or `.lock`"
2571        )));
2572    }
2573
2574    let full = format!("{HEADS_PREFIX}{}", body.name);
2575
2576    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2577
2578    if guard.view().refs.contains_key(&full) {
2579        return Err(Error::conflict(format!(
2580            "branch `{}` already exists",
2581            body.name
2582        )));
2583    }
2584
2585    // Resolve the target commit CID.
2586    let target_cid = match body.at.as_deref() {
2587        Some(cid_str) => {
2588            let cid = mnem_core::id::Cid::parse_str(cid_str)
2589                .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
2590            // Verify the CID decodes as a Commit block.
2591            let bs = guard.blockstore().clone();
2592            let bytes = bs
2593                .get(&cid)
2594                .map_err(|e| Error::internal(format!("blockstore error: {e}")))?
2595                .ok_or_else(|| {
2596                    Error::not_found(format!("block {cid_str} not found in blockstore"))
2597                })?;
2598            if from_canonical_bytes::<Commit>(&bytes).is_err() {
2599                return Err(Error::bad_request(format!(
2600                    "`{cid_str}` does not decode as a commit; \
2601                     use a commit CID (not an op CID)"
2602                )));
2603            }
2604            cid
2605        }
2606        None => guard.view().heads.first().cloned().ok_or_else(|| {
2607            Error::bad_request(
2608                "repository has no commits yet; pass `at` with a commit CID".to_string(),
2609            )
2610        })?,
2611    };
2612
2613    let head_str = target_cid.to_string();
2614    let new_repo = guard
2615        .update_ref(
2616            &full,
2617            None,
2618            Some(mnem_core::objects::RefTarget::normal(target_cid)),
2619            &body.author,
2620        )
2621        .map_err(Error::from)?;
2622    let op_id = new_repo.op_id().to_string();
2623    *guard = new_repo;
2624
2625    Ok(Json(json!({
2626        "schema": "mnem.v1.branch-create",
2627        "name": body.name,
2628        "head": head_str,
2629        "op_id": op_id,
2630        "created": true,
2631    })))
2632}
2633
2634// ---------- DELETE /v1/branches/:name ----------
2635
2636/// `DELETE /v1/branches/:name` - delete a branch by short name.
2637///
2638/// Removes `refs/heads/<name>`. Returns 404 if the branch does not
2639/// exist, 409 if the branch is the current head (i.e. its target equals
2640/// `view.heads.first()`).
2641///
2642/// Response schema: `mnem.v1.branch-delete`
2643/// ```json
2644/// {"deleted": "feature-x"}
2645/// ```
2646pub(crate) async fn delete_branch(
2647    State(s): State<AppState>,
2648    Path(name): Path<String>,
2649    Query(q): Query<DeleteQuery>,
2650) -> Result<Json<Value>, Error> {
2651    if name.trim().is_empty() {
2652        return Err(Error::bad_request("branch name must not be empty"));
2653    }
2654    if q.author.trim().is_empty() {
2655        return Err(Error::bad_request("author is required"));
2656    }
2657
2658    let full = format!("{HEADS_PREFIX}{name}");
2659
2660    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2661    let view = guard.view();
2662
2663    let prev = view
2664        .refs
2665        .get(&full)
2666        .cloned()
2667        .ok_or_else(|| Error::not_found(format!("branch `{name}` does not exist")))?;
2668
2669    // Refuse to delete the branch that currently points at HEAD.
2670    let current_head = view.heads.first().cloned();
2671    if let mnem_core::objects::RefTarget::Normal { target } = &prev {
2672        if Some(target) == current_head.as_ref() {
2673            return Err(Error::conflict(format!(
2674                "cannot delete branch `{name}`: it is the current branch (points at HEAD)"
2675            )));
2676        }
2677    }
2678
2679    let new_repo = guard
2680        .update_ref(&full, Some(&prev), None, &q.author)
2681        .map_err(Error::from)?;
2682    let op_id = new_repo.op_id().to_string();
2683    *guard = new_repo;
2684
2685    Ok(Json(json!({
2686        "schema": "mnem.v1.branch-delete",
2687        "deleted": name,
2688        "op_id": op_id,
2689    })))
2690}
2691
2692// ---------- GET/POST/DELETE /v1/tags ----------
2693
2694/// `GET /v1/tags` - list all tags.
2695///
2696/// Returns every ref whose name begins with `refs/tags/`, with its
2697/// target CID.
2698///
2699/// Response schema: `mnem.v1.tags`
2700/// ```json
2701/// {"schema": "mnem.v1.tags", "tags": [{"name": "v1.0", "target": "<cid>"}]}
2702/// ```
2703pub(crate) async fn get_tags(State(s): State<AppState>) -> Result<Json<Value>, Error> {
2704    let repo = s.repo.lock().map_err(|_| Error::locked())?;
2705    let view = repo.view();
2706
2707    let tags: Vec<Value> = view
2708        .refs
2709        .iter()
2710        .filter_map(|(name, target)| {
2711            let short = name.strip_prefix(TAGS_PREFIX)?;
2712            let target_str = match target {
2713                mnem_core::objects::RefTarget::Normal { target } => target.to_string(),
2714                mnem_core::objects::RefTarget::Conflicted { .. } => String::new(),
2715            };
2716            Some(json!({
2717                "name": short,
2718                "target": target_str,
2719            }))
2720        })
2721        .collect();
2722
2723    Ok(Json(json!({
2724        "schema": "mnem.v1.tags",
2725        "tags": tags,
2726    })))
2727}
2728
2729// ---------- POST /v1/tags ----------
2730
2731/// Request body for `POST /v1/tags`.
2732#[derive(Deserialize)]
2733pub(crate) struct CreateTagBody {
2734    /// Short tag name (e.g. `"v1.0"`). Stored as `refs/tags/<name>`.
2735    pub name: String,
2736    /// Optional commit CID to point the tag at. When absent,
2737    /// defaults to the current HEAD commit CID.
2738    #[serde(default)]
2739    pub target: Option<String>,
2740    /// Commit author recorded on the `update_ref` Operation.
2741    pub author: String,
2742}
2743
2744/// `POST /v1/tags` - create a tag.
2745///
2746/// Creates `refs/tags/<name>` pointing at `target` (or the current HEAD
2747/// commit CID when absent). Fails 400 if the name is invalid, 409 if the
2748/// tag already exists, 400 if the repo has no commits yet and no target is
2749/// supplied.
2750///
2751/// Response schema: `mnem.v1.tag-create`
2752/// ```json
2753/// {"schema": "mnem.v1.tag-create", "name": "v1.0", "target": "<cid>", "created": true}
2754/// ```
2755pub(crate) async fn post_tag(
2756    State(s): State<AppState>,
2757    Json(body): Json<CreateTagBody>,
2758) -> Result<Json<Value>, Error> {
2759    if body.name.trim().is_empty() {
2760        return Err(Error::bad_request("name is required"));
2761    }
2762    if body.name.len() > 255 {
2763        return Err(Error::bad_request(
2764            "tag name exceeds maximum length of 255 characters",
2765        ));
2766    }
2767    if body.author.trim().is_empty() {
2768        return Err(Error::bad_request("author is required"));
2769    }
2770    // Reuse the same refname validation as branches.
2771    let n = &body.name;
2772    if n.contains(' ')
2773        || n.contains('\t')
2774        || n.contains('\n')
2775        || n.contains('\x00')
2776        || n.contains('~')
2777        || n.contains('^')
2778        || n.contains(':')
2779        || n.contains('?')
2780        || n.contains('*')
2781        || n.contains('[')
2782        || n.contains('\\')
2783        || n.contains("@{")
2784        || n.contains("..")
2785        || n.contains("//")
2786        || n.starts_with('/')
2787        || n.ends_with('/')
2788        || n.ends_with('.')
2789        || n.ends_with(".lock")
2790    {
2791        return Err(Error::bad_request(format!(
2792            "invalid tag name `{n}`: may not contain spaces, control characters, \
2793             `~`, `^`, `:`, `?`, `*`, `[`, `\\`, `@{{`, `..`, `//`, \
2794             or start/end with `/`, or end with `.` or `.lock`"
2795        )));
2796    }
2797
2798    let full = format!("{TAGS_PREFIX}{}", body.name);
2799
2800    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2801
2802    if guard.view().refs.contains_key(&full) {
2803        return Err(Error::conflict(format!(
2804            "tag `{}` already exists",
2805            body.name
2806        )));
2807    }
2808
2809    // Resolve the target commit CID.
2810    let target_cid = match body.target.as_deref() {
2811        Some(cid_str) => {
2812            let cid = mnem_core::id::Cid::parse_str(cid_str)
2813                .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
2814            // Verify the CID decodes as a Commit block (not an op CID).
2815            let bs = guard.blockstore().clone();
2816            let bytes = bs
2817                .get(&cid)
2818                .map_err(|e| Error::internal(format!("blockstore error: {e}")))?
2819                .ok_or_else(|| {
2820                    Error::not_found(format!("block `{cid}` not found in blockstore"))
2821                })?;
2822            if from_canonical_bytes::<Commit>(&bytes).is_err() {
2823                return Err(Error::bad_request(format!(
2824                    "`{cid_str}` does not decode as a commit; \
2825                     use a commit CID (not an op CID)"
2826                )));
2827            }
2828            cid
2829        }
2830        None => guard.view().heads.first().cloned().ok_or_else(|| {
2831            Error::bad_request(
2832                "repository has no commits yet; pass `target` with a commit CID".to_string(),
2833            )
2834        })?,
2835    };
2836
2837    let target_str = target_cid.to_string();
2838    let new_repo = guard
2839        .update_ref(
2840            &full,
2841            None,
2842            Some(mnem_core::objects::RefTarget::normal(target_cid)),
2843            &body.author,
2844        )
2845        .map_err(Error::from)?;
2846    let op_id = new_repo.op_id().to_string();
2847    *guard = new_repo;
2848
2849    Ok(Json(json!({
2850        "schema": "mnem.v1.tag-create",
2851        "name": body.name,
2852        "target": target_str,
2853        "op_id": op_id,
2854        "created": true,
2855    })))
2856}
2857
2858// ---------- DELETE /v1/tags/:name ----------
2859
2860/// `DELETE /v1/tags/:name` - delete a tag by short name.
2861///
2862/// Removes `refs/tags/<name>`. Returns 404 if the tag does not exist.
2863/// Unlike branches there is no "current tag" concept, so no 409.
2864///
2865/// Response schema: `mnem.v1.tag-delete`
2866/// ```json
2867/// {"schema": "mnem.v1.tag-delete", "deleted": "v1.0"}
2868/// ```
2869pub(crate) async fn delete_tag(
2870    State(s): State<AppState>,
2871    Path(name): Path<String>,
2872    Query(q): Query<DeleteQuery>,
2873) -> Result<Json<Value>, Error> {
2874    if name.trim().is_empty() {
2875        return Err(Error::bad_request("tag name must not be empty"));
2876    }
2877    if q.author.trim().is_empty() {
2878        return Err(Error::bad_request("author is required"));
2879    }
2880
2881    let full = format!("{TAGS_PREFIX}{name}");
2882
2883    let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2884    let view = guard.view();
2885
2886    let prev = view
2887        .refs
2888        .get(&full)
2889        .cloned()
2890        .ok_or_else(|| Error::not_found(format!("tag `{name}` does not exist")))?;
2891
2892    let new_repo = guard
2893        .update_ref(&full, Some(&prev), None, &q.author)
2894        .map_err(Error::from)?;
2895    let op_id = new_repo.op_id().to_string();
2896    *guard = new_repo;
2897
2898    Ok(Json(json!({
2899        "schema": "mnem.v1.tag-delete",
2900        "deleted": name,
2901        "op_id": op_id,
2902    })))
2903}
2904
2905// ---------- POST /v1/diff ----------
2906
2907/// Default maximum diff entries returned per category (added/removed/changed)
2908/// per tree (nodes, edges).
2909const DIFF_DEFAULT_LIMIT: usize = 500;
2910
2911/// Hard cap on diff entries per category per tree.
2912const DIFF_MAX_LIMIT: usize = 2_000;
2913
2914/// Query parameters for `POST /v1/diff`.
2915#[derive(Deserialize, Default)]
2916pub(crate) struct DiffQueryParams {
2917    /// Cap the number of entries in each added/removed/changed bucket.
2918    /// Default 500, max 2000.
2919    #[serde(default)]
2920    pub limit: Option<usize>,
2921}
2922
2923/// Request body for `POST /v1/diff`.
2924#[derive(Deserialize)]
2925pub(crate) struct DiffBody {
2926    /// CID of the "from" side: either a commit CID or an op CID.
2927    pub from: String,
2928    /// CID of the "to" side: either a commit CID or an op CID.
2929    pub to: String,
2930}
2931
2932/// Resolve a caller-supplied CID string to a `Commit`. The string may be:
2933///
2934/// 1. An op CID - decoded as an `Operation`, then the first head commit CID
2935///    is resolved from the embedded `View`.
2936/// 2. A commit CID directly.
2937///
2938/// Returns `(commit_cid_string, Commit)` on success, or a 400/404 `Error`.
2939fn resolve_cid_to_commit(
2940    bs: &dyn mnem_core::store::Blockstore,
2941    cid_str: &str,
2942) -> Result<(mnem_core::id::Cid, Commit), Error> {
2943    let cid = mnem_core::id::Cid::parse_str(cid_str)
2944        .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
2945    let bytes = bs
2946        .get(&cid)
2947        .map_err(|e| Error::internal(format!("blockstore error: {e}")))?
2948        .ok_or_else(|| Error::not_found(format!("block `{cid_str}` not found in blockstore")))?;
2949
2950    // Try to decode as an Operation first (it has a `view` field).
2951    if let Ok(op) = from_canonical_bytes::<Operation>(&bytes) {
2952        // Resolve the view block to get the head commit CID.
2953        let view_bytes = bs
2954            .get(&op.view)
2955            .map_err(|e| Error::internal(format!("blockstore error reading view: {e}")))?
2956            .ok_or_else(|| {
2957                Error::internal(format!("view block {} missing from blockstore", op.view))
2958            })?;
2959        let view: mnem_core::objects::View = from_canonical_bytes(&view_bytes)
2960            .map_err(|e| Error::internal(format!("decode view: {e}")))?;
2961        let commit_cid = view
2962            .heads
2963            .into_iter()
2964            .next()
2965            .ok_or_else(|| Error::bad_request(format!("op `{cid_str}` has no head commits")))?;
2966        let commit_bytes = bs
2967            .get(&commit_cid)
2968            .map_err(|e| Error::internal(format!("blockstore error reading commit: {e}")))?
2969            .ok_or_else(|| {
2970                Error::not_found(format!(
2971                    "commit block {} (from op `{cid_str}`) not found in blockstore",
2972                    commit_cid
2973                ))
2974            })?;
2975        let commit: Commit = from_canonical_bytes(&commit_bytes)
2976            .map_err(|e| Error::internal(format!("decode commit: {e}")))?;
2977        return Ok((commit_cid, commit));
2978    }
2979
2980    // Try to decode as a Commit directly.
2981    if let Ok(commit) = from_canonical_bytes::<Commit>(&bytes) {
2982        return Ok((cid, commit));
2983    }
2984
2985    Err(Error::bad_request(format!(
2986        "`{cid_str}` does not decode as an op or commit CID"
2987    )))
2988}
2989
2990/// `POST /v1/diff` - structural diff between two commits (or ops).
2991///
2992/// Body: `{"from": "<cid>", "to": "<cid>"}`
2993/// Query: `?limit=N` (default 500, max 2000) - cap per added/removed/changed bucket.
2994///
2995/// Both `from` and `to` accept either a commit CID or an op CID (the op's head
2996/// commit is resolved automatically).
2997///
2998/// Response schema: `mnem.v1.diff`
2999pub(crate) async fn post_diff(
3000    State(s): State<AppState>,
3001    Query(params): Query<DiffQueryParams>,
3002    Json(body): Json<DiffBody>,
3003) -> Result<Json<Value>, Error> {
3004    // Clamp limit.
3005    let limit = params
3006        .limit
3007        .unwrap_or(DIFF_DEFAULT_LIMIT)
3008        .min(DIFF_MAX_LIMIT);
3009    if limit == 0 {
3010        return Err(Error::bad_request("limit must be >= 1"));
3011    }
3012
3013    let repo = s.repo.lock().map_err(|_| Error::locked())?;
3014    let bs = repo.blockstore().clone();
3015    drop(repo); // release lock before potentially slow diff walks
3016
3017    let (from_cid, from_commit) = resolve_cid_to_commit(bs.as_ref(), &body.from)?;
3018    let (to_cid, to_commit) = resolve_cid_to_commit(bs.as_ref(), &body.to)?;
3019
3020    // Diff node trees.
3021    let node_changes = mnem_core::prolly::diff(bs.as_ref(), &from_commit.nodes, &to_commit.nodes)
3022        .map_err(|e| Error::internal(format!("node diff failed: {e}")))?;
3023
3024    // Diff edge trees.
3025    let edge_changes = mnem_core::prolly::diff(bs.as_ref(), &from_commit.edges, &to_commit.edges)
3026        .map_err(|e| Error::internal(format!("edge diff failed: {e}")))?;
3027
3028    // Build node response buckets.
3029    let mut nodes_added: Vec<Value> = Vec::new();
3030    let mut nodes_removed: Vec<Value> = Vec::new();
3031    let mut nodes_changed: Vec<Value> = Vec::new();
3032
3033    for entry in &node_changes {
3034        match entry {
3035            mnem_core::prolly::DiffEntry::Added { value, .. } => {
3036                if nodes_added.len() < limit {
3037                    if let Some(node) = node_from_bs(bs.as_ref(), value) {
3038                        nodes_added.push(json!({
3039                            "id": node.id.to_uuid_string(),
3040                            "ntype": node.ntype,
3041                            "summary": node.summary,
3042                        }));
3043                    }
3044                }
3045            }
3046            mnem_core::prolly::DiffEntry::Removed { value, .. } => {
3047                if nodes_removed.len() < limit {
3048                    if let Some(node) = node_from_bs(bs.as_ref(), value) {
3049                        nodes_removed.push(json!({
3050                            "id": node.id.to_uuid_string(),
3051                            "ntype": node.ntype,
3052                            "summary": node.summary,
3053                        }));
3054                    }
3055                }
3056            }
3057            mnem_core::prolly::DiffEntry::Changed { before, after, .. } => {
3058                if nodes_changed.len() < limit {
3059                    if let Some(after_node) = node_from_bs(bs.as_ref(), after) {
3060                        let before_val = node_from_bs(bs.as_ref(), before).map(|n| {
3061                            json!({
3062                                "id": n.id.to_uuid_string(),
3063                                "ntype": n.ntype,
3064                                "summary": n.summary,
3065                            })
3066                        });
3067                        nodes_changed.push(json!({
3068                            "id": after_node.id.to_uuid_string(),
3069                            "before": before_val,
3070                            "after": {
3071                                "id": after_node.id.to_uuid_string(),
3072                                "ntype": after_node.ntype,
3073                                "summary": after_node.summary,
3074                            },
3075                        }));
3076                    }
3077                }
3078            }
3079        }
3080    }
3081
3082    // Build edge response buckets.
3083    let mut edges_added: Vec<Value> = Vec::new();
3084    let mut edges_removed: Vec<Value> = Vec::new();
3085
3086    for entry in &edge_changes {
3087        match entry {
3088            mnem_core::prolly::DiffEntry::Added { value, .. } => {
3089                if edges_added.len() < limit {
3090                    if let Some(edge) = edge_from_bs(bs.as_ref(), value) {
3091                        edges_added.push(json!({
3092                            "id": edge.id.to_uuid_string(),
3093                            "etype": edge.etype,
3094                            "src": edge.src.to_uuid_string(),
3095                            "dst": edge.dst.to_uuid_string(),
3096                        }));
3097                    }
3098                }
3099            }
3100            mnem_core::prolly::DiffEntry::Removed { value, .. } => {
3101                if edges_removed.len() < limit {
3102                    if let Some(edge) = edge_from_bs(bs.as_ref(), value) {
3103                        edges_removed.push(json!({
3104                            "id": edge.id.to_uuid_string(),
3105                            "etype": edge.etype,
3106                            "src": edge.src.to_uuid_string(),
3107                            "dst": edge.dst.to_uuid_string(),
3108                        }));
3109                    }
3110                }
3111            }
3112            mnem_core::prolly::DiffEntry::Changed { .. } => {
3113                // Edges rarely change in place (etype/src/dst are immutable
3114                // by convention); if they do, we emit an empty changed array
3115                // as the spec requires but do not expand the entries.
3116            }
3117        }
3118    }
3119
3120    Ok(Json(json!({
3121        "schema": "mnem.v1.diff",
3122        "from": from_cid.to_string(),
3123        "to": to_cid.to_string(),
3124        "nodes": {
3125            "added": nodes_added,
3126            "removed": nodes_removed,
3127            "changed": nodes_changed,
3128        },
3129        "edges": {
3130            "added": edges_added,
3131            "removed": edges_removed,
3132            "changed": [],
3133        },
3134    })))
3135}
3136
3137// ---------- GET /v1/blocks/{cid} ----------
3138
3139/// Output format for `GET /v1/blocks/{cid}`.
3140#[derive(Deserialize, Default)]
3141#[serde(rename_all = "lowercase")]
3142pub(crate) enum BlockFormat {
3143    /// Decode CBOR and return as JSON (default).
3144    #[default]
3145    Json,
3146    /// Return raw bytes hex-encoded in a JSON wrapper.
3147    Raw,
3148    /// Return raw CBOR bytes with `Content-Type: application/cbor`.
3149    Cbor,
3150}
3151
3152/// Query parameters for `GET /v1/blocks/{cid}`.
3153#[derive(Deserialize, Default)]
3154pub(crate) struct BlockParams {
3155    /// Output format: `json` (default), `raw`, or `cbor`.
3156    #[serde(default)]
3157    pub format: BlockFormat,
3158}
3159
3160/// `GET /v1/blocks/{cid}` - fetch a single raw block by its CID.
3161///
3162/// The `{cid}` path segment must be a valid multibase-encoded CID string
3163/// (e.g. base32 upper `BAFY...`). No special characters, so no wildcard
3164/// capture is required.
3165///
3166/// Query params:
3167/// - `?format=json` (default) - decode CBOR, return as JSON
3168/// - `?format=raw` - return raw bytes as hex in a JSON wrapper
3169/// - `?format=cbor` - return raw CBOR with `Content-Type: application/cbor`
3170///
3171/// Errors:
3172/// - 400 if the CID string cannot be parsed
3173/// - 404 if the block is not in the store
3174/// - On CBOR decode failure (json format): 200 with `"data": null, "error": "..."`
3175pub(crate) async fn get_block(
3176    State(s): State<AppState>,
3177    Path(cid_str): Path<String>,
3178    Query(params): Query<BlockParams>,
3179) -> Result<impl IntoResponse, Error> {
3180    // Parse the CID string.
3181    let cid = mnem_core::id::Cid::parse_str(&cid_str)
3182        .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
3183
3184    // Acquire repo lock, clone the blockstore, then drop the lock
3185    // before any potentially slow I/O.
3186    let repo = s.repo.lock().map_err(|_| Error::locked())?;
3187    let bs = repo.blockstore().clone();
3188    drop(repo);
3189
3190    // Fetch the raw block bytes.
3191    let data = bs
3192        .get(&cid)
3193        .map_err(|e| Error::internal(format!("blockstore read: {e}")))?
3194        .ok_or_else(|| Error::not_found(format!("block `{cid_str}` not found in store")))?;
3195
3196    match params.format {
3197        BlockFormat::Cbor => {
3198            // Return raw CBOR bytes directly.
3199            Ok(([(CONTENT_TYPE, "application/cbor")], data.to_vec()).into_response())
3200        }
3201        BlockFormat::Raw => {
3202            // Hex-encode the raw bytes and wrap in a JSON envelope.
3203            let hex: String = data.iter().map(|b| format!("{b:02x}")).collect();
3204            Ok(Json(json!({
3205                "schema": "mnem.v1.block",
3206                "cid": cid.to_string(),
3207                "format": "raw",
3208                "hex": hex,
3209            }))
3210            .into_response())
3211        }
3212        BlockFormat::Json => {
3213            // Attempt to decode the CBOR as generic IPLD and convert to JSON.
3214            match from_canonical_bytes::<Ipld>(&data) {
3215                Ok(ipld) => Ok(Json(json!({
3216                    "schema": "mnem.v1.block",
3217                    "cid": cid.to_string(),
3218                    "format": "json",
3219                    "data": ipld_to_json(&ipld),
3220                }))
3221                .into_response()),
3222                Err(e) => Ok(Json(json!({
3223                    "schema": "mnem.v1.block",
3224                    "cid": cid.to_string(),
3225                    "format": "json",
3226                    "data": Value::Null,
3227                    "error": format!("decode failed: {e}"),
3228                }))
3229                .into_response()),
3230            }
3231        }
3232    }
3233}
3234
3235/// Load and decode a [`Node`] from the blockstore by its value CID.
3236/// Returns `None` on any decode / store error (missing block, wrong codec).
3237fn node_from_bs(bs: &dyn mnem_core::store::Blockstore, cid: &mnem_core::id::Cid) -> Option<Node> {
3238    let bytes = bs.get(cid).ok()??;
3239    from_canonical_bytes::<Node>(&bytes).ok()
3240}
3241
3242/// Load and decode an [`Edge`] from the blockstore by its value CID.
3243/// Returns `None` on any decode / store error.
3244fn edge_from_bs(
3245    bs: &dyn mnem_core::store::Blockstore,
3246    cid: &mnem_core::id::Cid,
3247) -> Option<mnem_core::objects::Edge> {
3248    let bytes = bs.get(cid).ok()??;
3249    from_canonical_bytes::<mnem_core::objects::Edge>(&bytes).ok()
3250}
3251
3252// ---------- POST /v1/merge ----------
3253
3254/// `strategy` field on `POST /v1/merge` body. Defaults to `manual`.
3255#[derive(Deserialize, Default)]
3256#[serde(rename_all = "snake_case")]
3257pub(crate) enum MergeStrategyParam {
3258    #[default]
3259    Manual,
3260    Ours,
3261    Theirs,
3262}
3263
3264/// Request body for `POST /v1/merge`.
3265#[derive(Deserialize)]
3266pub(crate) struct MergeBody {
3267    /// CID of the left (current-branch) commit.
3268    pub left: String,
3269    /// CID of the right (incoming-branch) commit.
3270    pub right: String,
3271    /// Conflict-resolution strategy. Defaults to `"manual"`.
3272    #[serde(default)]
3273    pub strategy: MergeStrategyParam,
3274}
3275
3276/// `POST /v1/merge` - 3-way merge two commit CIDs.
3277///
3278/// Body: `{"left": "<cid>", "right": "<cid>", "strategy": "manual"|"ours"|"theirs"}`
3279///
3280/// `strategy` defaults to `"manual"` when omitted.
3281///
3282/// Response (HTTP 200 for all outcomes):
3283/// - `{"status": "fast_forward", "commit": "<cid>"}`
3284/// - `{"status": "clean", "commit": "<cid>"}`
3285/// - `{"status": "conflicts", "conflicts": <MergeConflicts>}`
3286pub(crate) async fn post_merge(
3287    State(s): State<AppState>,
3288    Json(body): Json<MergeBody>,
3289) -> Result<Json<Value>, Error> {
3290    use mnem_core::repo::merge::{MergeOutcome, MergeStrategy, merge_three_way};
3291    use mnem_core::store::MemoryOpHeadsStore;
3292
3293    let repo = s.repo.lock().map_err(|_| Error::locked())?;
3294    let bs = repo.blockstore().clone();
3295    drop(repo); // release lock before slow merge walks
3296
3297    // Reject same-CID early: calling merge_three_way with left==right
3298    // returns FastForward but is almost certainly a caller mistake.
3299    if body.left == body.right {
3300        return Err(Error::bad_request(
3301            "left and right must be different commit CIDs",
3302        ));
3303    }
3304
3305    let (left_cid, _) = resolve_cid_to_commit(bs.as_ref(), &body.left)?;
3306    let (right_cid, _) = resolve_cid_to_commit(bs.as_ref(), &body.right)?;
3307
3308    let strategy = match body.strategy {
3309        MergeStrategyParam::Manual => MergeStrategy::Manual,
3310        MergeStrategyParam::Ours => MergeStrategy::Ours,
3311        MergeStrategyParam::Theirs => MergeStrategy::Theirs,
3312    };
3313
3314    // The _oph parameter is unused in merge_three_way; pass a dummy.
3315    let dummy_ohs: std::sync::Arc<dyn mnem_core::store::OpHeadsStore> =
3316        std::sync::Arc::new(MemoryOpHeadsStore::new());
3317    let outcome = merge_three_way(&bs, &dummy_ohs, left_cid, right_cid, strategy).map_err(|e| {
3318        // NoCommonAncestor means the caller supplied two unrelated
3319        // commits - that's a 400, not a server error.
3320        use mnem_core::error::RepoError;
3321        match &e {
3322            mnem_core::error::Error::Repo(RepoError::NoCommonAncestor) => Error::bad_request(
3323                "left and right commits share no common ancestor; \
3324                         cannot merge unrelated histories",
3325            ),
3326            _ => Error::internal(format!("merge failed: {e}")),
3327        }
3328    })?;
3329
3330    let response = match outcome {
3331        MergeOutcome::FastForward(cid) => json!({
3332            "status": "fast_forward",
3333            "commit": cid.to_string(),
3334        }),
3335        MergeOutcome::Clean(cid) => json!({
3336            "status": "clean",
3337            "commit": cid.to_string(),
3338        }),
3339        MergeOutcome::Conflicts(conflicts) => json!({
3340            "status": "conflicts",
3341            "conflicts": conflicts,
3342        }),
3343    };
3344
3345    Ok(Json(response))
3346}
3347
3348#[cfg(test)]
3349mod gap01_tests {
3350    use super::*;
3351    use mnem_core::id::NodeId;
3352    use mnem_core::objects::Node;
3353    use mnem_core::retrieve::RetrievedItem;
3354    use proptest::prelude::*;
3355
3356    fn fake_item(score: f32) -> RetrievedItem {
3357        // `Node::new` with no props is enough here; only `id` and
3358        // `rendered` are read downstream.
3359        let node = Node::new(NodeId::new_v7(), "Gap01Probe");
3360        RetrievedItem::new(node, "rendered preview".to_string(), 4, score)
3361    }
3362
3363    #[test]
3364    fn confidence_zero_on_empty() {
3365        assert_eq!(gap01_compute_confidence(&[]), 0.0);
3366    }
3367
3368    #[test]
3369    fn confidence_zero_on_singleton() {
3370        assert_eq!(gap01_compute_confidence(&[fake_item(1.0)]), 0.0);
3371    }
3372
3373    #[test]
3374    fn confidence_high_when_tail_far_below_top() {
3375        let items = vec![fake_item(1.0), fake_item(0.9), fake_item(0.01)];
3376        let c = gap01_compute_confidence(&items);
3377        assert!(c > 0.9, "expected >0.9, got {c}");
3378    }
3379
3380    #[test]
3381    fn confidence_low_when_flat() {
3382        let items = vec![fake_item(1.0), fake_item(0.99), fake_item(0.98)];
3383        let c = gap01_compute_confidence(&items);
3384        assert!(c < 0.1, "expected <0.1, got {c}");
3385    }
3386
3387    #[test]
3388    fn suggested_neighbors_empty_below_top_seeds() {
3389        let items = vec![fake_item(1.0), fake_item(0.9), fake_item(0.8)];
3390        assert!(gap01_suggested_neighbors(&items).is_empty());
3391    }
3392
3393    #[test]
3394    fn suggested_neighbors_skips_top_seeds() {
3395        let items = vec![
3396            fake_item(1.0),
3397            fake_item(0.9),
3398            fake_item(0.8),
3399            fake_item(0.7),
3400            fake_item(0.6),
3401        ];
3402        let n = gap01_suggested_neighbors(&items);
3403        assert_eq!(n.len(), 2);
3404        // `via` is always "adjacency".
3405        for entry in &n {
3406            assert_eq!(entry["via"], "adjacency");
3407        }
3408    }
3409
3410    #[test]
3411    fn suggested_neighbors_bounded_by_max() {
3412        let items: Vec<_> = (0..100).map(|i| fake_item(1.0 - i as f32 * 0.01)).collect();
3413        let n = gap01_suggested_neighbors(&items);
3414        assert!(n.len() <= GAP01_MAX_NEIGHBOURS);
3415    }
3416
3417    proptest! {
3418    /// Gap 01 proptest: the `suggested_neighbors` list is
3419    /// always a strict subset of the adjacency (ranked items)
3420    /// passed in. The proof is trivial by construction
3421    /// (`.iter().skip(GAP01_TOP_SEEDS).take(GAP01_MAX_NEIGHBOURS)`)
3422    /// but the property pins the invariant so that any future
3423    /// refactor which drifts into pulling IDs from a different
3424    /// source (e.g. a sibling lookup) has to rewrite this test.
3425    #[test]
3426    fn suggested_neighbors_always_subset_of_adjacency(
3427    scores in proptest::collection::vec(-1.0f32..1.0f32, 0..32),
3428    ) {
3429    let items: Vec<_> = scores.iter().map(|&s| fake_item(s)).collect();
3430    let neighbours = gap01_suggested_neighbors(&items);
3431    // Every `id` in the neighbour list must appear in the
3432    // original adjacency (ranked items).
3433    let ids: Vec<String> = items
3434    .iter()
3435    .map(|it| it.node.id.to_uuid_string())
3436    .collect();
3437    for entry in &neighbours {
3438    let nid = entry["id"].as_str().expect("id field");
3439    prop_assert!(
3440    ids.iter().any(|i| i == nid),
3441    "neighbour id {nid} not in adjacency"
3442    );
3443    }
3444    // And the cardinality is bounded.
3445    prop_assert!(neighbours.len() <= GAP01_MAX_NEIGHBOURS);
3446    }
3447    }
3448}
3449
3450#[cfg(test)]
3451mod tests {
3452    use super::*;
3453
3454    fn check(days: u64, expected_year: u64, expected_month: u8, expected_day: u8) {
3455        let (y, m, d) = days_to_ymd(days);
3456        assert_eq!(
3457            (y, m, d),
3458            (expected_year, expected_month, expected_day),
3459            "days_to_ymd({days}) = ({y},{m},{d}), want ({expected_year},{expected_month},{expected_day})"
3460        );
3461    }
3462
3463    #[test]
3464    fn epoch() {
3465        check(0, 1970, 1, 1);
3466    }
3467
3468    #[test]
3469    fn epoch_plus_one() {
3470        check(1, 1970, 1, 2);
3471    }
3472
3473    #[test]
3474    fn start_of_february_1970() {
3475        check(31, 1970, 2, 1);
3476    }
3477
3478    #[test]
3479    fn start_of_march_1970_non_leap() {
3480        check(59, 1970, 3, 1);
3481    }
3482
3483    #[test]
3484    fn second_year() {
3485        check(365, 1971, 1, 1);
3486    }
3487
3488    #[test]
3489    fn year_2000_leap_day() {
3490        check(11016, 2000, 2, 29);
3491    }
3492
3493    #[test]
3494    fn year_2000_day_before_leap() {
3495        check(11015, 2000, 2, 28);
3496    }
3497
3498    #[test]
3499    fn year_2000_day_after_leap() {
3500        check(11017, 2000, 3, 1);
3501    }
3502
3503    #[test]
3504    fn year_2024_leap_day() {
3505        check(19782, 2024, 2, 29);
3506    }
3507
3508    #[test]
3509    fn year_1972_leap_day() {
3510        check(789, 1972, 2, 29);
3511    }
3512
3513    #[test]
3514    fn year_2024_day_before_leap() {
3515        check(19781, 2024, 2, 28);
3516    }
3517
3518    #[test]
3519    fn year_2024_day_after_leap() {
3520        check(19783, 2024, 3, 1);
3521    }
3522
3523    #[test]
3524    fn december_year_end_1970() {
3525        check(364, 1970, 12, 31);
3526    }
3527
3528    #[test]
3529    fn year_2100_is_not_leap_feb_28() {
3530        check(47540, 2100, 2, 28);
3531    }
3532
3533    #[test]
3534    fn year_2100_is_not_leap_next_day_is_march() {
3535        check(47541, 2100, 3, 1);
3536    }
3537
3538    #[test]
3539    fn year_2100_start() {
3540        check(47482, 2100, 1, 1);
3541    }
3542}