Skip to main content

lunaris_storage_moon/
lib.rs

1//! `MoonStorage` — `StoragePort` impl backed by Moon (Redis-compatible RESP).
2//!
3//! RFC 0001 Wave 1C: every `StoragePort` method now routes through per-scope
4//! keyspace helpers (`keyspace::{scope_prefix, ft_index_name, graph_key, mq_topic}`).
5//! Per-scope FT indices, graph keys, and MQ topics are created lazily on first
6//! write via `ensure_scope`.
7//!
8//! Per blueprint §6, every method is a thin pass-through to a Moon native command:
9//!
10//! | trait method      | Moon command(s)                                                         |
11//! |-------------------|-------------------------------------------------------------------------|
12//! | `atomic_write`    | `TXN.BEGIN` + per-op (`HSET` / `FT.UPSERT` / `GRAPH.QUERY MERGE`) + `TXN.COMMIT` |
13//! | `vector_search`   | `FT.SEARCH` (with `TEMPORAL.SNAPSHOT_AT` when `as_of` is `Some`)         |
14//! | `graph_traverse`  | `GRAPH.QUERY` (with `TEMPORAL.SNAPSHOT_AT` when `as_of` is `Some`)       |
15//! | `scan_range`      | `SCAN ... MATCH <prefix>*` then `HGET` per matched key                   |
16//! | `read_as_of`      | `TEMPORAL.SNAPSHOT_AT` then `HGET <key> v`                              |
17//! | `publish`         | `MQ.PUSH`                                                               |
18//! | `subscribe`       | `MQ.POP ... BLOCK` polling stream                                       |
19//! | `capabilities`    | constant — Moon-native everything                                       |
20//!
21//! ## Lazy per-scope init
22//!
23//! On first write under a scope, `ensure_scope` creates:
24//! - `FT.CREATE lunaris_{scope}_{kind}_idx` for each of chunks / entities / facts / communities
25//! - `GRAPH.CREATE lunaris_{scope}_graph`
26//!
27//! Subsequent calls for an already-initialized scope skip the Moon round-trips via
28//! an in-memory `initialized_scopes` set (lock-free read path once initialized).
29//!
30//! ## Threat model snapshot (T-01-03-*)
31//!
32//! * `WriteOp::GraphNode { label, ... }` and `WriteOp::GraphEdge { rel, ... }` are
33//!   interpolated into Cypher. Callers MUST validate `label` / `rel` against
34//!   `^[A-Za-z_][A-Za-z0-9_]*$` — see `crates/lunaris-storage-moon/src/atomic.rs` rustdoc.
35//!   Phase 4 (`OPS-04` audit) will move the guard into the trait.
36//! * Connection is cleartext RESP over TCP — Moon is treated as trusted infra inside the
37//!   same network boundary as the Lunaris process. TLS lands in Phase 5.
38
39#![deny(rust_2018_idioms, unreachable_pub)]
40#![forbid(unsafe_code)]
41
42// 0.6.2 task 9 — AS_OF honesty guard for the KV readers. Public so callers
43// and tests can read the policy (`is_historical`) without a live Moon.
44pub mod as_of;
45pub mod atomic;
46pub mod client;
47pub mod graph;
48// W2-L2 — FT.INVALIDATE_RANGE raw RESP escape hatch (UC-G3 force-push invalidation).
49// pub(crate): only called from `MoonStorage::invalidate_range` in this file.
50pub(crate) mod invalidate;
51pub mod keyspace;
52pub mod keyword;
53pub mod kv;
54// hotkeys-observability — HOTKEYS raw RESP path (typed SDK has no wrapper).
55pub(crate) mod hotkeys;
56// ft-navigate-recall — FT.NAVIGATE raw RESP path (typed SDK lacks a DECAY slot).
57pub(crate) mod navigate;
58pub mod queue;
59pub mod repair;
60pub(crate) mod retry;
61pub mod scopes;
62// 0.7.0 task 22 — connect-time single-shard guard (RFC 0008 §6 Option C).
63// Public so operators/tests can read the policy and the probe key set without a
64// live Moon, and so an out-of-crate live harness can exercise the classifiers.
65pub mod shards;
66pub mod vector;
67pub mod version;
68
69pub use client::MoonClient;
70pub use shards::ShardTopology;
71pub use version::{MIN_MOON_VERSION, MoonVersion, MoonVersionCheck, check_moon_version};
72
73use std::collections::HashSet;
74use std::sync::Arc;
75
76use async_trait::async_trait;
77use bytes::Bytes;
78use futures::stream::BoxStream;
79use lunaris_core::storage::port::MaintenanceHint;
80use lunaris_core::{
81    CypherQuery, Filter, GraphDecay, GraphResult, Hlc, KeywordHit, KeywordPort, Lsn, NavigateHit,
82    NavigateSpec, QueueMsg, Row, Scope, ScopePage, StorageCapabilities, StorageError, StoragePort,
83    VectorHit, WriteOp,
84};
85use parking_lot::Mutex;
86
87use crate::keyspace::{ft_index_name, graph_key};
88
89/// `StoragePort` backed by a single Moon RESP connection manager.
90///
91/// `initialized_scopes` tracks which scopes have had their FT indices and graph
92/// key created (lazy init on first write). `Mutex` is held only during the
93/// brief check + insert — never across `.await` points.
94#[derive(Debug, Clone)]
95pub struct MoonStorage {
96    pub(crate) client: MoonClient,
97    queue_native: bool,
98    /// Set of scopes whose FT indices + graph key have been created on Moon.
99    /// `parking_lot::Mutex` (not `std::sync::Mutex`) per CLAUDE.md lock discipline.
100    /// The lock is NEVER held across an `.await` — it is taken, the bool is checked,
101    /// optionally the scope is inserted, and the lock is dropped BEFORE the async
102    /// Moon calls in `ensure_scope`.
103    initialized_scopes: Arc<Mutex<HashSet<String>>>,
104}
105
106impl MoonStorage {
107    /// Open a connection to Moon at `url` (`moon://host:port[?ws=workspace]`),
108    /// creating FT vector indices at the default dimension
109    /// ([`client::DEFAULT_VECTOR_DIM`] = 768, matching EmbeddingGemma-300M).
110    pub async fn connect(url: &str) -> Result<Self, StorageError> {
111        Self::connect_with_dim(url, crate::client::DEFAULT_VECTOR_DIM).await
112    }
113
114    /// Like [`MoonStorage::connect`], but creates the FT vector indices at
115    /// `dim` instead of the default 768. `dim` MUST be `> 0`. Moon's
116    /// `FT.CREATE` has no upper cap, so a 1536-d embedder (OpenAI
117    /// `text-embedding-3`) works against Moon out of the box.
118    ///
119    /// ## Operator footgun — existing index won't auto-resize
120    ///
121    /// Moon's `FT.CREATE` is idempotent and does NOT update an existing
122    /// index's schema. If a Moon instance already holds a 768-d `chunks`
123    /// index from a prior run, reopening with a 1536-d embedder leaves the
124    /// 768-d index in place; the mismatch surfaces only on the first vector
125    /// write. Drop the stale index first (`FT.DROPINDEX <name>`).
126    pub async fn connect_with_dim(url: &str, dim: usize) -> Result<Self, StorageError> {
127        let client = MoonClient::connect_with_dim(url, dim).await?;
128        let queue_native = crate::queue::supports_native_queue(&client).await?;
129        Ok(Self { client, queue_native, initialized_scopes: Arc::new(Mutex::new(HashSet::new())) })
130    }
131
132    /// Borrow the underlying client (used by integration tests).
133    pub fn client(&self) -> &MoonClient {
134        &self.client
135    }
136
137    /// Lazily ensure per-scope FT indices and graph key exist on Moon.
138    ///
139    /// On first call for a given scope, creates:
140    /// - `FT.CREATE lunaris_{scope}_{kind}_idx` for chunks / entities / facts / communities
141    /// - `GRAPH.CREATE lunaris_{scope}_graph`
142    ///
143    /// Idempotent: "already exists" errors from Moon are swallowed. Subsequent calls
144    /// for the same scope return immediately (in-memory set check, no Moon I/O).
145    ///
146    /// ## Lock discipline
147    ///
148    /// The `Mutex` is locked only to read/write the `HashSet<String>` — it is
149    /// dropped BEFORE any `.await` call so it is NEVER held across an await point.
150    async fn ensure_scope(&self, scope: &Scope) -> Result<(), StorageError> {
151        let scope_str = scope.as_str().to_string();
152
153        // Fast path: scope already initialized — lock, check, drop.
154        {
155            let guard = self.initialized_scopes.lock();
156            if guard.contains(&scope_str) {
157                return Ok(());
158            }
159        } // lock dropped here
160
161        // Slow path: create FT indices and graph on Moon.
162        self.create_scope_indexes(scope).await?;
163
164        // Mark initialized — lock, insert, drop.
165        {
166            let mut guard = self.initialized_scopes.lock();
167            guard.insert(scope_str);
168        } // lock dropped here
169
170        Ok(())
171    }
172
173    /// Create per-scope FT indices and graph key. Called at most once per scope
174    /// (guarded by `ensure_scope`'s in-memory set).
175    async fn create_scope_indexes(&self, scope: &Scope) -> Result<(), StorageError> {
176        // Single-sourced: the FT vector dimension is configured once on the
177        // underlying client (`connect`/`connect_with_dim`); per-scope indices
178        // inherit it — and the `?quant=` choice — so engine-level
179        // `Lunaris::open` sizing flows through here. Schema construction is
180        // shared with the legacy global `ensure_indexes` via
181        // `client::create_lunaris_index_named` so the two sites can never
182        // diverge.
183        let dim = self.client.dim;
184        let typed = self.client.typed();
185
186        for kind in &["chunks", "entities", "facts", "communities"] {
187            let idx_name = ft_index_name(scope, kind);
188            // The FT prefix must match the key shape written by `atomic.rs::VectorUpsert`:
189            // `{ft_index_name(scope, kind)}:{id_hex}`.
190            let prefix = format!("{idx_name}:");
191            // moon-v051-perf-exploit W1: thread the connect-time `?ef=` choice
192            // through per-scope creation too (quantization already flowed; ef
193            // is the same sticky FT.CREATE-time knob — see client.rs
194            // `parse_ef_runtime` for why there is no hardcoded default).
195            crate::client::create_lunaris_index_named_ef(
196                &typed,
197                &idx_name,
198                kind,
199                &prefix,
200                dim,
201                self.client.quantization,
202                self.client.ef_runtime,
203            )
204            .await?;
205        }
206
207        // Create per-scope graph. Moon does not auto-create graphs on first GRAPH.QUERY.
208        let gkey = graph_key(scope);
209        let typed = self.client.typed();
210        match typed.graph().create(&gkey).await {
211            Ok(_) => {}
212            Err(e) => {
213                let msg = e.to_string();
214                if !(msg.contains("already exists") || msg.contains("Graph already exists")) {
215                    return Err(crate::client::moon_err(e));
216                }
217            }
218        }
219
220        Ok(())
221    }
222}
223
224#[async_trait]
225impl StoragePort for MoonStorage {
226    /// RFC 0001 Wave 1C: lazy per-scope init before writing, then route all ops
227    /// through scope-prefixed keys / indices.
228    async fn atomic_write(&self, scope: &Scope, ops: &[WriteOp]) -> Result<Lsn, StorageError> {
229        self.ensure_scope(scope).await?;
230        crate::atomic::atomic_write(&self.client, scope, ops).await
231    }
232
233    /// observability-rollout-maturity — override the additive default with a
234    /// real Moon `PING` so a dead/stalled backend surfaces as `Err` on the
235    /// `/healthz` rollout-cutback probe. Bounded by `LUNARIS_MOON_OP_TIMEOUT`.
236    async fn health_check(&self) -> Result<(), StorageError> {
237        self.client.ping().await
238    }
239
240    /// moon-v051-perf-exploit W1: Moon override of the default no-op. On
241    /// `BulkIngestComplete` at/above `LUNARIS_MOON_COMPACT_MIN` vector
242    /// upserts, force-compacts the scope's vector indexes so recall hits the
243    /// compacted HNSW + exact-rerank segments instead of brute-force mutable
244    /// scans — see `vector::maybe_compact_after_bulk_ingest` for the full
245    /// contract (missing-index tolerance, threshold parsing).
246    async fn maintenance_hint(
247        &self,
248        scope: &Scope,
249        hint: MaintenanceHint,
250    ) -> Result<(), StorageError> {
251        match hint {
252            MaintenanceHint::BulkIngestComplete { vector_upserts } => {
253                crate::vector::maybe_compact_after_bulk_ingest(&self.client, scope, vector_upserts)
254                    .await
255            }
256            // `MaintenanceHint` is #[non_exhaustive]: future hints are
257            // advisory by contract, so an unknown one is a no-op, not an error.
258            _ => Ok(()),
259        }
260    }
261
262    #[allow(clippy::too_many_arguments)]
263    async fn vector_search(
264        &self,
265        scope: &Scope,
266        index: &str,
267        query: &[f32],
268        k: usize,
269        filter: Option<&Filter>,
270        as_of: Option<Hlc>,
271        rerank: bool,
272    ) -> Result<Vec<VectorHit>, StorageError> {
273        // Belt-and-suspenders: retry ONCE on a transient connection fault so a
274        // backend flip never bubbles `broken pipe` up through recall. The SDK's
275        // ConnectionManager reconnects underneath; this catches the one command
276        // that races the reconnect. See `crate::retry`.
277        crate::retry::with_conn_retry(|| {
278            crate::vector::vector_search(
279                &self.client,
280                scope,
281                index,
282                query,
283                k,
284                filter,
285                as_of,
286                rerank,
287            )
288        })
289        .await
290    }
291
292    async fn graph_traverse(
293        &self,
294        scope: &Scope,
295        query: &CypherQuery,
296        as_of: Option<Hlc>,
297    ) -> Result<GraphResult, StorageError> {
298        crate::graph::graph_traverse(&self.client, scope, query, as_of).await
299    }
300
301    async fn graph_traverse_decayed(
302        &self,
303        scope: &Scope,
304        query: &CypherQuery,
305        as_of: Option<Hlc>,
306        decay: Option<&GraphDecay>,
307    ) -> Result<GraphResult, StorageError> {
308        match decay {
309            None => crate::graph::graph_traverse(&self.client, scope, query, as_of).await,
310            Some(d) => {
311                crate::graph::graph_traverse_decayed(&self.client, scope, query, as_of, d).await
312            }
313        }
314    }
315
316    async fn vector_navigate(
317        &self,
318        scope: &Scope,
319        index: &str,
320        query: &[f32],
321        k: usize,
322        spec: &NavigateSpec,
323    ) -> Result<Vec<NavigateHit>, StorageError> {
324        crate::navigate::vector_navigate(&self.client, scope, index, query, k, spec).await
325    }
326
327    async fn hot_keys(&self, count: usize) -> Result<Vec<lunaris_core::HotKey>, StorageError> {
328        crate::hotkeys::hot_keys(&self.client, count).await
329    }
330
331    async fn scan_range(
332        &self,
333        scope: &Scope,
334        prefix: &[u8],
335        as_of: Option<Hlc>,
336    ) -> Result<BoxStream<'_, Result<(Bytes, Bytes), StorageError>>, StorageError> {
337        crate::kv::scan_range(&self.client, scope, prefix, as_of).await
338    }
339
340    async fn read_as_of(
341        &self,
342        scope: &Scope,
343        key: &[u8],
344        as_of: Hlc,
345    ) -> Result<Option<Row<Bytes>>, StorageError> {
346        crate::kv::read_as_of(&self.client, scope, key, as_of).await
347    }
348
349    /// Moon has no KV version chain — see [`crate::as_of`] for the full
350    /// rationale and the upstream `TemporalKvIndex` path. Declaring this
351    /// `false` is one half of the contract; `kv::read_as_of` refusing a
352    /// historical pin with `StorageError::NotSupported` is the other.
353    fn supports_historical_kv_reads(&self) -> bool {
354        crate::as_of::HISTORICAL_KV_READS
355    }
356
357    /// HOOK-05 idempotency sidecar (ADD task moon-parity-honesty): gives Moon
358    /// a real dedupe-key lookup. Before this, the trait-default `Ok(None)`
359    /// fall-through minted duplicate episodes for every replayed dedupe key
360    /// (proved live 2026-07-14) — idempotency was documented as available on
361    /// the embedded backend only, a boundary this closes.
362    async fn lookup_by_dedupe_key(
363        &self,
364        scope: &Scope,
365        dedupe_key: &str,
366    ) -> Result<Option<Lsn>, StorageError> {
367        crate::kv::lookup_dedupe(&self.client, scope, dedupe_key).await
368    }
369
370    async fn insert_dedupe_key(
371        &self,
372        scope: &Scope,
373        dedupe_key: &str,
374        lsn: Lsn,
375    ) -> Result<(), StorageError> {
376        crate::kv::insert_dedupe(&self.client, scope, dedupe_key, lsn).await
377    }
378
379    async fn publish(
380        &self,
381        scope: &Scope,
382        topic: &str,
383        partition: u16,
384        payload: Bytes,
385    ) -> Result<u64, StorageError> {
386        crate::queue::publish(&self.client, scope, topic, partition, payload).await
387    }
388
389    async fn subscribe(
390        &self,
391        scope: &Scope,
392        group: &str,
393        topic: &str,
394        partition: u16,
395    ) -> Result<BoxStream<'static, Result<QueueMsg, StorageError>>, StorageError> {
396        crate::queue::subscribe(self.client.clone(), scope, group, topic, partition).await
397    }
398
399    /// Plan 04 D-12 — see `crate::queue::queue_length` (private) for the raw
400    /// `MQ.LENGTH` escape hatch rationale.
401    async fn queue_depth(
402        &self,
403        scope: &Scope,
404        topic: &str,
405        partition: u16,
406    ) -> Result<u64, StorageError> {
407        crate::queue::queue_length(&self.client, scope, topic, partition).await
408    }
409
410    /// W4.6 / D6.3 — non-destructive range read; see
411    /// `crate::queue::queue_range` for why this is `XRANGE` and not the MQ
412    /// consumer surface.
413    async fn queue_range(
414        &self,
415        scope: &Scope,
416        topic: &str,
417        partition: u16,
418        from_ms: Option<u64>,
419        to_ms: Option<u64>,
420        limit: usize,
421    ) -> Result<Vec<QueueMsg>, StorageError> {
422        crate::queue::queue_range(&self.client, scope, topic, partition, from_ms, to_ms, limit)
423            .await
424    }
425
426    /// Cross-scope enumeration via `SCAN MATCH lunaris:*` + key parse.
427    /// Q-U2 lock — lazy SCAN-derived. See `crate::scopes` for the cursor
428    /// model and the Moon-SCAN-cursor vs scope-string-cursor tradeoff.
429    async fn list_scopes(
430        &self,
431        prefix: Option<&str>,
432        limit: usize,
433        cursor: Option<&str>,
434    ) -> Result<ScopePage, StorageError> {
435        crate::scopes::list_scopes(&self.client, prefix, limit, cursor).await
436    }
437
438    /// Bulk-invalidate FT index records via `FT.INVALIDATE_RANGE`.
439    ///
440    /// ## Wire shape
441    ///
442    /// ```text
443    /// FT.INVALIDATE_RANGE <index> <node_id_field> <node_id_value>
444    ///                     <hlc_wall_field> <hlc_wall_lo> <hlc_wall_hi>
445    /// ```
446    ///
447    /// Returns the integer count of deleted records as `u64`.
448    ///
449    /// ## Escape hatch
450    ///
451    /// `moon-client` v0.1.x does not expose a typed wrapper for
452    /// `FT.INVALIDATE_RANGE`. We reach the underlying
453    /// `redis::aio::MultiplexedConnection` via `MoonClient::inner_mut()` on a
454    /// local clone — the same documented pattern used by the HSCAN escape hatch
455    /// in `kv.rs` (the only other permitted raw-RESP site in this crate per
456    /// Phase 1.5 STORE-09 constraints).
457    ///
458    /// ## Error mapping
459    ///
460    /// - Moon `WRONGTYPE` (index does not exist) → `StorageError::Backend`
461    ///   containing `"WRONGTYPE"`. The `Lunaris::invalidate_range` fan-out
462    ///   treats this as warn-and-skip (degraded mode).
463    /// - Any other Moon error → `StorageError::Backend`.
464    #[allow(clippy::too_many_arguments)]
465    async fn invalidate_range(
466        &self,
467        scope: &Scope,
468        index: &str,
469        node_id_field: &str,
470        node_id_value: &str,
471        hlc_wall_field: &str,
472        hlc_wall_lo_inclusive: i64,
473        hlc_wall_hi_inclusive: i64,
474    ) -> Result<u64, StorageError> {
475        crate::invalidate::invalidate_range(
476            &self.client,
477            scope,
478            index,
479            node_id_field,
480            node_id_value,
481            hlc_wall_field,
482            hlc_wall_lo_inclusive,
483            hlc_wall_hi_inclusive,
484        )
485        .await
486    }
487
488    fn capabilities(&self) -> StorageCapabilities {
489        StorageCapabilities {
490            // Moon supports AS_OF for FT.SEARCH (vector + keyword) and VALID_AT
491            // for GRAPH.QUERY, but plain HGET does NOT accept temporal clauses.
492            // Per moon/docs/guides/temporal.mdx: "Bi-temporal fields are
493            // currently limited to graph entities (nodes/edges). KV temporal
494            // versioning uses a sparse index" (the sparse index is for
495            // transactional MVCC isolation, NOT for AS_OF reads). Lunaris's
496            // KV `read_as_of` therefore returns current state on Moon —
497            // historical KV reads need a Lunaris-layer versioned-key encoding
498            // (Gap 8 — tracked for follow-up phase). Reporting `false` here
499            // is one half of the contract; `kv::read_as_of` refusing a
500            // historical pin with `StorageError::NotSupported` is the other
501            // (see `crate::as_of`). Live-measurement gap fix 2026-04-21;
502            // 0.7.0 removed the second backend this used to defer to.
503            bi_temporal_native: false,
504            graph_native: true,
505            rerank_native: true,
506            queue_native: self.queue_native,
507            // Moon's FT.CREATE has no dimension cap — report the dimension the
508            // adapter actually created its indices at (default 768d matching
509            // EmbeddingGemma-300M; `connect_with_dim` / `Lunaris::open` size it
510            // to the embedder). This stays an accurate description of what the
511            // FT `vec` field will accept.
512            max_vector_dim: self.client.dim as u32,
513            // Gap 9 closure (2026-04-21): `ensure_indexes` now declares
514            // `SchemaField::Text("content")` on chunks/entities/facts/communities
515            // and `WriteOp::VectorUpsert` writes the `content` field via
516            // `extract_content_for_index` (text/fact_text/name/summary per
517            // index — see that function's table). Moon's
518            // SDK `hybrid_search` (3-weight + sparse_field) therefore resolves
519            // `@content` and `fuse_rrf` opts into `RrfFusion::Moon` for one
520            // round-trip server-side fusion. If the schema regresses (e.g. an
521            // older Moon binary that ignores extra_schema), set this back to
522            // `false` to force the always-correct local fusion path.
523            native_rrf: true,
524            // RFC 0001 §3.6 — Moon's soft FT-index limit is ~512 per node
525            // before recall p99 degrades (Moon docs §6.4). Above this,
526            // operators should consider workspace-level pooling (future RFC).
527            max_scopes_recommended: 512,
528            cypher_dialect: lunaris_core::CypherDialect::Legacy,
529            graph_decay_native: true,
530            graph_navigate_native: true,
531        }
532    }
533}
534
535#[async_trait]
536impl KeywordPort for MoonStorage {
537    /// Wave 2.5A: `KeywordPort::keyword_search` now carries `scope: &Scope`
538    /// (RFC 0001 §3.4 amendment). The Moon backend threads scope through to
539    /// `keyword::keyword_search` which routes to the per-scope FT index
540    /// (`ft_index_name(scope, index)`). Previously this impl used `Scope::dev()`
541    /// as a placeholder — that placeholder is now replaced by the caller-supplied scope.
542    async fn keyword_search(
543        &self,
544        scope: &Scope,
545        index: &str,
546        query: &str,
547        k: usize,
548        filter: Option<&Filter>,
549        as_of: Option<Hlc>,
550    ) -> Result<Vec<KeywordHit>, StorageError> {
551        // Same one-shot reconnect guard as `vector_search` — keyword recall is
552        // the other read path that broke on a dropped socket in production.
553        crate::retry::with_conn_retry(|| {
554            crate::keyword::keyword_search(&self.client, scope, index, query, k, filter, as_of)
555        })
556        .await
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    /// Compile-time assertion that `MoonStorage` is dyn-compatible.
565    #[allow(dead_code)]
566    fn _moonstorage_is_storage_port() {
567        fn assert_storage_port<T: StoragePort + ?Sized>() {}
568        assert_storage_port::<MoonStorage>();
569        assert_storage_port::<dyn StoragePort>();
570    }
571
572    #[test]
573    fn capabilities_match_moon_profile() {
574        // We can't construct a real `MoonStorage` without a connection, but we can match
575        // the `capabilities()` body shape directly.
576        let want = StorageCapabilities {
577            bi_temporal_native: false,
578            graph_native: true,
579            rerank_native: true,
580            queue_native: true,
581            max_vector_dim: 768,
582            native_rrf: true,
583            max_scopes_recommended: 512,
584            cypher_dialect: lunaris_core::CypherDialect::Legacy,
585            graph_decay_native: true,
586            graph_navigate_native: true,
587        };
588        assert!(
589            !want.bi_temporal_native,
590            "Moon does not natively support KV bi-temporal reads (HGET ignores AS_OF); only FT.SEARCH AS_OF + GRAPH.QUERY VALID_AT are temporal — Gap 8 fix 2026-04-21"
591        );
592        assert!(want.graph_native);
593        assert!(want.rerank_native);
594        assert!(want.queue_native);
595        assert_eq!(want.max_vector_dim, 768);
596        assert!(
597            want.native_rrf,
598            "Moon HYBRID FT.SEARCH now resolves @content via the SchemaField::Text added by ensure_indexes; fuse_rrf opts into RrfFusion::Moon — Gap 9 closure 2026-04-21"
599        );
600        assert_eq!(
601            want.max_scopes_recommended, 512,
602            "Moon FT soft limit is ~512 indices per node (RFC 0001 §3.6)"
603        );
604    }
605}