Skip to main content

khive_runtime/
runtime.rs

1//! KhiveRuntime — composable handle to all storage capabilities.
2
3use std::sync::{Arc, RwLock};
4
5use khive_db::StorageBackend;
6use khive_gate::{ActorRef, AllowAllGate, GateRef, GateRequest};
7use khive_storage::{EntityStore, EventStore, GraphStore, NoteStore, SqlAccess};
8use khive_types::{EdgeEndpointRule, Namespace};
9use lattice_embed::{EmbeddingModel, EmbeddingService};
10
11use crate::error::RuntimeResult;
12
13// ---- BackendId ----
14
15/// Identifies a named backend in a multi-backend deployment.
16///
17/// The `main` backend is the default single-backend name. Multi-backend deployments
18/// assign each `[[backends]]` entry a distinct `BackendId`. The
19/// [`SubstrateCoordinator`](kkernel::coordinator::SubstrateCoordinator) in `kkernel`
20/// uses `BackendId` for node-to-backend resolution and cross-backend edge routing.
21///
22/// A single-backend `KhiveRuntime` always has `BackendId("main")` by default.
23/// The boot path in `kkernel` or `khive-mcp` sets the id via `RuntimeConfig::backend_id`
24/// when constructing per-pack runtimes.
25#[derive(Clone, Debug, PartialEq, Eq, Hash)]
26pub struct BackendId(pub String);
27
28impl BackendId {
29    /// The default single-backend name.
30    pub const MAIN: &'static str = "main";
31
32    /// Construct from a string name.
33    pub fn new(name: impl Into<String>) -> Self {
34        Self(name.into())
35    }
36
37    /// The default `main` backend id.
38    pub fn main() -> Self {
39        Self(Self::MAIN.to_string())
40    }
41
42    /// Return the backend name as a `&str`.
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48impl std::fmt::Display for BackendId {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(&self.0)
51    }
52}
53
54// ---- Sealed token ----
55
56mod private {
57    #[derive(Clone, Debug)]
58    pub(crate) struct Sealed;
59}
60
61/// Authorization proof that a caller is permitted to access a specific namespace.
62///
63/// Created by [`VerbRegistry::dispatch`] after the gate approves the request.
64/// The sealed inner field prevents external code from constructing a token
65/// without going through the authorization path.
66#[derive(Clone, Debug)]
67pub struct NamespaceToken {
68    namespace: Namespace,
69    actor: ActorRef,
70    _sealed: private::Sealed,
71}
72
73impl NamespaceToken {
74    /// Mint an authorized token. Only callable from within `khive-runtime`.
75    pub(crate) fn mint_authorized(namespace: Namespace, actor: ActorRef) -> Self {
76        Self {
77            namespace,
78            actor,
79            _sealed: private::Sealed,
80        }
81    }
82
83    /// Convenience constructor for the local namespace with an anonymous actor.
84    ///
85    /// Only callable from within `khive-runtime`. External callers must use
86    /// [`KhiveRuntime::authorize`] to mint tokens.
87    // Used only in #[cfg(test)] blocks within this crate's src/ files.
88    #[allow(dead_code)]
89    pub(crate) fn local() -> Self {
90        Self::mint_authorized(Namespace::local(), ActorRef::anonymous())
91    }
92
93    /// Convenience constructor for a specific namespace with an anonymous actor.
94    ///
95    /// Only callable from within `khive-runtime`. External callers must use
96    /// [`KhiveRuntime::authorize`] to mint tokens.
97    // Used only in #[cfg(test)] blocks within this crate's src/ files.
98    #[allow(dead_code)]
99    pub(crate) fn for_namespace(ns: Namespace) -> Self {
100        Self::mint_authorized(ns, ActorRef::anonymous())
101    }
102
103    /// Return the namespace this token authorises access to.
104    pub fn namespace(&self) -> &Namespace {
105        &self.namespace
106    }
107
108    /// Return the actor reference embedded in this token.
109    pub fn actor(&self) -> &ActorRef {
110        &self.actor
111    }
112
113    /// Return a new token with the same actor but a different namespace.
114    ///
115    /// Used by packs that apply a namespace policy (e.g. the KG pack overrides the
116    /// caller's namespace to `Namespace::local()` so that entity/edge/note records
117    /// always land in the shared graph).
118    pub fn with_namespace(&self, ns: Namespace) -> Self {
119        Self::mint_authorized(ns, self.actor.clone())
120    }
121}
122
123// ---- RuntimeConfig ----
124
125/// Runtime configuration.
126///
127/// The `db_path` and `embedding_model` fields are deprecated in favour of
128/// constructing the backend externally and calling [`KhiveRuntime::from_backend`].
129/// They remain for backward compatibility with tests and single-binary deployments.
130#[derive(Clone, Debug)]
131pub struct RuntimeConfig {
132    /// Path to the SQLite database file. `None` = in-memory (tests).
133    ///
134    /// Deprecated: use [`KhiveRuntime::from_backend`] instead. The boot path
135    /// constructs backends from `khive.toml` (`AppConfig`) and passes them to
136    /// `from_backend`. Direct `db_path` usage persists only in tests.
137    pub db_path: Option<std::path::PathBuf>,
138    /// Namespace used when no explicit namespace is provided.
139    pub default_namespace: Namespace,
140    /// Local embedding model. `None` disables embedding and hybrid vector search;
141    /// `hybrid_search` then falls back to text-only.
142    ///
143    /// Deprecated: embedding engines move to a per-pack `EmbedderRegistry`.
144    /// This field persists for backward compatibility until the embedder registry
145    /// is fully plumbed.
146    pub embedding_model: Option<EmbeddingModel>,
147    /// Additional embedding models to make available by request name.
148    ///
149    /// `embedding_model` remains the default used by existing `embed()` and
150    /// `embed_batch()` callers. This list adds non-default models that can be
151    /// selected with `embedder(name)`, `embed_with_model(...)`, memory
152    /// `remember.embedding_model`, and memory `recall.embedding_model`.
153    pub additional_embedding_models: Vec<EmbeddingModel>,
154    /// Authorization gate consulted before each verb dispatch.
155    /// Default: `AllowAllGate` (permissive). For production policy enforcement,
156    /// plug in a Rego- or capability-witness-backed impl.
157    pub gate: GateRef,
158    /// Names of packs the transport layer should register into the VerbRegistry.
159    /// The transport layer (e.g. `khive-mcp`) reads this list and instantiates
160    /// the matching concrete pack types. Unknown names are reported as errors
161    /// by the transport, not silently ignored.
162    /// Default: `["kg"]`.
163    pub packs: Vec<String>,
164    /// Identifies this runtime's backend in a multi-backend deployment.
165    ///
166    /// Set by the boot path when constructing per-pack runtimes from `khive.toml`.
167    /// Single-backend deployments use the default `BackendId::MAIN`.
168    pub backend_id: BackendId,
169}
170
171/// Parse a comma- or whitespace-separated pack list from a single string.
172///
173/// Empty entries are dropped, surrounding whitespace is trimmed.
174pub fn parse_pack_list(s: &str) -> Vec<String> {
175    s.split(|c: char| c == ',' || c.is_whitespace())
176        .map(str::trim)
177        .filter(|s| !s.is_empty())
178        .map(str::to_owned)
179        .collect()
180}
181
182impl Default for RuntimeConfig {
183    fn default() -> Self {
184        let db_path = std::env::var("HOME")
185            .ok()
186            .map(|h| std::path::PathBuf::from(h).join(".khive/khive-graph.db"));
187        let embedding_model = std::env::var("KHIVE_EMBEDDING_MODEL")
188            .ok()
189            .and_then(|s| s.parse().ok())
190            .or(Some(EmbeddingModel::AllMiniLmL6V2));
191        let additional_embedding_models = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
192            .ok()
193            .map(|s| parse_embedding_model_list(&s))
194            .unwrap_or_else(|| vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]);
195        let packs = std::env::var("KHIVE_PACKS")
196            .ok()
197            .map(|s| parse_pack_list(&s))
198            .filter(|v| !v.is_empty())
199            .unwrap_or_else(|| {
200                vec![
201                    "kg",
202                    "gtd",
203                    "memory",
204                    "brain",
205                    "comm",
206                    "schedule",
207                    "knowledge",
208                ]
209                .into_iter()
210                .map(String::from)
211                .collect()
212            });
213        Self {
214            db_path,
215            default_namespace: Namespace::local(),
216            embedding_model,
217            additional_embedding_models,
218            gate: Arc::new(AllowAllGate),
219            packs,
220            backend_id: BackendId::main(),
221        }
222    }
223}
224
225// ---- KhiveRuntime ----
226
227/// Composable runtime handle used by the MCP server.
228///
229/// Wraps a `StorageBackend` and provides namespace-scoped accessor methods
230/// for each storage capability, plus a lazily-loaded embedder.
231#[derive(Clone)]
232pub struct KhiveRuntime {
233    backend: Arc<StorageBackend>,
234    config: RuntimeConfig,
235    /// Pack-extensible embedder registry.
236    ///
237    /// Shared across clones via `Arc<RwLock<_>>` so that
238    /// [`register_embedder`](Self::register_embedder) after clone is visible
239    /// to all handles. Built-in lattice models are pre-registered during
240    /// construction; packs may add more via [`PackRuntime::register_embedders`].
241    embedder_registry: Arc<std::sync::RwLock<crate::embedder_registry::EmbedderRegistry>>,
242    default_embedder_name: Arc<str>,
243    /// Pack-extensible edge endpoint rules. Shared across clones
244    /// via `Arc<RwLock<_>>`; installed once by the transport after the
245    /// `VerbRegistry` is built. Empty until installed
246    edge_rules: Arc<RwLock<Vec<EdgeEndpointRule>>>,
247    /// Pack-aggregated valid entity and note kind strings.
248    ///
249    /// Installed by the transport layer after building the `VerbRegistry`.
250    /// When non-empty, `create_entity`, `create_note_inner`, and `import_kg`
251    /// reject kinds not in these sets. When empty (no packs loaded, e.g.
252    /// bare runtime in unit tests), kind validation is skipped — the pack
253    /// handler layer is the primary enforcement point.
254    valid_entity_kinds: Arc<RwLock<Vec<String>>>,
255    valid_note_kinds: Arc<RwLock<Vec<String>>>,
256}
257
258impl KhiveRuntime {
259    /// Create a new runtime with the given config.
260    ///
261    /// The config's `db_path` is used to open or create the SQLite backend.
262    /// For the preferred boot path in multi-backend deployments, use
263    /// [`from_backend`](Self::from_backend) instead.
264    pub fn new(config: RuntimeConfig) -> RuntimeResult<Self> {
265        let backend = match &config.db_path {
266            Some(path) => {
267                if let Some(parent) = path.parent() {
268                    std::fs::create_dir_all(parent).ok();
269                }
270                StorageBackend::sqlite(path)?
271            }
272            None => StorageBackend::memory()?,
273        };
274        // Run versioned migrations (V1..V17) at startup so file-backed and
275        // in-memory DBs both have proposals_open (V15) and the embedding_model
276        // columns (V16/V17) before any pack handler runs.  Migration is
277        // idempotent — already-applied versions are skipped.  A failure here
278        // aborts construction so the caller sees a clear error rather than a
279        // cryptic "no such table" on the first verb dispatch.
280        {
281            let mut writer = backend.pool().try_writer()?;
282            khive_db::run_migrations(writer.conn_mut())?;
283        }
284        register_configured_embedding_models(&backend, &config)?;
285        let (registry, default_embedder_name) = build_embedder_registry(&config);
286        Ok(Self {
287            backend: Arc::new(backend),
288            config,
289            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
290            default_embedder_name,
291            edge_rules: Arc::new(RwLock::new(Vec::new())),
292            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
293            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
294        })
295    }
296
297    /// Open a runtime for read-only inspection (no model registration, no DB creation).
298    ///
299    /// Runs migrations (idempotent) but skips `register_configured_embedding_models`,
300    /// so `engine list` / `engine status` cannot mutate the registry as a side effect.
301    /// Returns `None` when `db_path` is `None` and the default DB does not exist.
302    pub fn new_readonly(config: RuntimeConfig) -> RuntimeResult<Self> {
303        let backend = match &config.db_path {
304            Some(path) => StorageBackend::sqlite(path)?,
305            None => StorageBackend::memory()?,
306        };
307        {
308            let mut writer = backend.pool().try_writer()?;
309            khive_db::run_migrations(writer.conn_mut())?;
310        }
311        let (registry, default_embedder_name) = build_embedder_registry(&config);
312        Ok(Self {
313            backend: Arc::new(backend),
314            config,
315            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
316            default_embedder_name,
317            edge_rules: Arc::new(RwLock::new(Vec::new())),
318            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
319            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
320        })
321    }
322
323    /// Construct a runtime from an already-opened backend.
324    ///
325    /// This is the preferred constructor for multi-backend deployments. The caller
326    /// (boot path in `kkernel` or `khive-mcp`) opens each backend from `khive.toml`,
327    /// then constructs a `KhiveRuntime` per pack using this method.
328    ///
329    /// The returned runtime has `db_path = None` and `embedding_model = None`; all
330    /// storage access is through the provided `backend`. Set `backend_id` and
331    /// `default_namespace` via the config builder pattern if non-defaults are needed.
332    pub fn from_backend(backend: Arc<StorageBackend>, config: RuntimeConfig) -> Self {
333        if let Err(err) = register_configured_embedding_models(&backend, &config) {
334            tracing::warn!(error = %err, "failed to register configured embedding models");
335        }
336        let (registry, default_embedder_name) = build_embedder_registry(&config);
337        Self {
338            backend,
339            config,
340            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
341            default_embedder_name,
342            edge_rules: Arc::new(RwLock::new(Vec::new())),
343            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
344            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
345        }
346    }
347
348    /// Create an in-memory runtime (for tests and ephemeral use).
349    pub fn memory() -> RuntimeResult<Self> {
350        Self::new(RuntimeConfig {
351            db_path: None,
352            default_namespace: Namespace::local(),
353            embedding_model: None,
354            additional_embedding_models: vec![],
355            gate: Arc::new(AllowAllGate),
356            packs: vec!["kg".to_string()],
357            backend_id: BackendId::main(),
358        })
359    }
360
361    /// Return the [`BackendId`] for this runtime's backend.
362    ///
363    /// Used by the [`SubstrateCoordinator`](kkernel::coordinator::SubstrateCoordinator)
364    /// to identify which backend owns a given node, and to detect cross-backend merges.
365    pub fn backend_id(&self) -> &BackendId {
366        &self.config.backend_id
367    }
368
369    /// Return a reference to the runtime config.
370    pub fn config(&self) -> &RuntimeConfig {
371        &self.config
372    }
373
374    /// Return a reference to the underlying storage backend.
375    pub fn backend(&self) -> &StorageBackend {
376        &self.backend
377    }
378
379    // ---- Store accessors (token-scoped) ----
380
381    /// Get an EntityStore scoped to the token's namespace.
382    pub fn entities(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EntityStore>> {
383        Ok(self
384            .backend
385            .entities_for_namespace(token.namespace().as_str())?)
386    }
387
388    /// Get a GraphStore scoped to the token's namespace.
389    pub fn graph(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn GraphStore>> {
390        Ok(self
391            .backend
392            .graph_for_namespace(token.namespace().as_str())?)
393    }
394
395    /// Get a NoteStore scoped to the token's namespace.
396    pub fn notes(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn NoteStore>> {
397        Ok(self
398            .backend
399            .notes_for_namespace(token.namespace().as_str())?)
400    }
401
402    /// Get an EventStore scoped to the token's namespace.
403    pub fn events(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EventStore>> {
404        Ok(self
405            .backend
406            .events_for_namespace(token.namespace().as_str())?)
407    }
408
409    /// Get the raw SQL access capability (for ad-hoc queries).
410    pub fn sql(&self) -> Arc<dyn SqlAccess> {
411        self.backend.sql()
412    }
413
414    /// Get a VectorStore for the configured embedding model, scoped to the token's namespace.
415    ///
416    /// Returns `Unconfigured("embedding_model")` if no model is set.
417    pub fn vectors(
418        &self,
419        token: &NamespaceToken,
420    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
421        let model = self.resolve_embedding_model(None)?;
422        self.vectors_for_embedding_model(token, model)
423    }
424
425    /// Get a VectorStore for a specific named embedding model, scoped to the token's namespace.
426    ///
427    /// Accepts both built-in lattice model names/aliases and custom provider names
428    /// registered via [`register_embedder`](Self::register_embedder). Lattice names
429    /// are routed through the enum-backed path; custom provider names use the
430    /// provider's declared `dimensions()` directly so that the vector store key
431    /// is consistent with how vectors were written during `remember`/`recall`.
432    pub fn vectors_for_model(
433        &self,
434        token: &NamespaceToken,
435        model_name: &str,
436    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
437        // Try the lattice enum path first (handles aliases like "paraphrase").
438        if let Some(model) = parse_embedding_model_alias(model_name) {
439            // Only proceed via the lattice path if this model is actually in the
440            // registry; otherwise fall through to the custom-provider path.
441            let key = model.to_string();
442            let in_registry = self
443                .embedder_registry
444                .read()
445                .map(|reg| reg.contains(&key))
446                .unwrap_or(false);
447            if in_registry {
448                return self.vectors_for_embedding_model(token, model);
449            }
450        }
451        // Custom provider path: look up dimensions from the registry and build
452        // the vector store using the sanitized provider name as the table key.
453        let dims = {
454            let registry = self.embedder_registry.read().map_err(|_| {
455                crate::RuntimeError::Internal("embedder registry lock poisoned".into())
456            })?;
457            registry
458                .get_provider(model_name)
459                .map(|p| p.dimensions())
460                .ok_or_else(|| crate::RuntimeError::UnknownModel(model_name.to_string()))?
461        };
462        let model_key = sanitize_key(model_name);
463        Ok(self.backend.vectors_for_namespace(
464            &model_key,
465            model_name,
466            dims,
467            token.namespace().as_str(),
468        )?)
469    }
470
471    fn vectors_for_embedding_model(
472        &self,
473        token: &NamespaceToken,
474        model: EmbeddingModel,
475    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
476        Ok(self.backend.vectors_for_namespace(
477            &vec_model_key(model),
478            &model.to_string(),
479            model.dimensions(),
480            token.namespace().as_str(),
481        )?)
482    }
483
484    /// Get a TextSearch index for the token's namespace entity corpus.
485    pub fn text(
486        &self,
487        token: &NamespaceToken,
488    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
489        let key = format!("entities_{}", sanitize_key(token.namespace().as_str()));
490        Ok(self.backend.text(&key)?)
491    }
492
493    /// Get a TextSearch index for the token's namespace notes corpus.
494    pub fn text_for_notes(
495        &self,
496        token: &NamespaceToken,
497    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
498        let key = format!("notes_{}", sanitize_key(token.namespace().as_str()));
499        Ok(self.backend.text(&key)?)
500    }
501
502    /// Mint an authorization token for the given namespace.
503    ///
504    /// Consults the configured [`Gate`] before minting. With the default
505    /// `AllowAllGate` this always succeeds. When a real policy-backed gate is
506    /// installed, this method enforces it and returns `PermissionDenied` on
507    /// denial.
508    pub fn authorize(&self, ns: Namespace) -> RuntimeResult<NamespaceToken> {
509        let actor = ActorRef::anonymous();
510        let req = GateRequest::new(
511            actor.clone(),
512            ns.clone(),
513            "authorize",
514            serde_json::Value::Null,
515        );
516        match self.config.gate.check(&req) {
517            Ok(ref decision) if decision.is_allow() => {
518                if let khive_gate::GateDecision::Allow { ref obligations } = decision {
519                    if !obligations.is_empty() {
520                        tracing::debug!(
521                            namespace = %ns.as_str(),
522                            "authorize: obligations={:?}",
523                            obligations
524                        );
525                    }
526                }
527                Ok(NamespaceToken::mint_authorized(ns, actor))
528            }
529            Ok(khive_gate::GateDecision::Deny { reason }) => {
530                Err(crate::RuntimeError::PermissionDenied {
531                    verb: "authorize".to_string(),
532                    reason,
533                })
534            }
535            Ok(_) => Err(crate::RuntimeError::PermissionDenied {
536                verb: "authorize".to_string(),
537                reason: "gate denied".to_string(),
538            }),
539            Err(e) => Err(crate::RuntimeError::Internal(format!("gate error: {e}"))),
540        }
541    }
542
543    /// Install the pack-aggregated edge endpoint rules.
544    ///
545    /// Called by the transport layer after the `VerbRegistry` is built so
546    /// that runtime-layer edge validation can consult pack rules. Idempotent:
547    /// later calls overwrite the previous rule set.
548    pub fn install_edge_rules(&self, rules: Vec<EdgeEndpointRule>) {
549        if let Ok(mut guard) = self.edge_rules.write() {
550            *guard = rules;
551        }
552    }
553
554    /// Install the pack-aggregated valid entity and note kinds.
555    ///
556    /// Called by the transport layer after the `VerbRegistry` is built so that
557    /// runtime-layer entity/note creation and import validate kind strings against
558    /// the merged pack vocabulary. Idempotent: later calls overwrite previous sets.
559    ///
560    /// When no kinds are installed (empty lists), kind validation is skipped at
561    /// the runtime layer. The pack handler layer remains the primary enforcement
562    /// point; this provides defense-in-depth for direct Rust callers and import.
563    pub fn install_kind_registry(&self, entity_kinds: Vec<String>, note_kinds: Vec<String>) {
564        if let Ok(mut guard) = self.valid_entity_kinds.write() {
565            *guard = entity_kinds;
566        }
567        if let Ok(mut guard) = self.valid_note_kinds.write() {
568            *guard = note_kinds;
569        }
570    }
571
572    /// Validate that `kind` is a pack-registered entity kind.
573    ///
574    /// Returns `Ok(())` when no kinds are installed (bare runtime without packs).
575    /// Returns `InvalidInput` when kinds are installed and `kind` is not among them.
576    pub(crate) fn validate_entity_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
577        let guard = self.valid_entity_kinds.read().map_err(|_| {
578            crate::RuntimeError::Internal("entity kind registry lock poisoned".into())
579        })?;
580        if guard.is_empty() {
581            return Ok(());
582        }
583        if guard.iter().any(|k| k == kind) {
584            Ok(())
585        } else {
586            Err(crate::RuntimeError::InvalidInput(format!(
587                "unknown entity kind {kind:?}; valid: {}",
588                guard.join(", ")
589            )))
590        }
591    }
592
593    /// Validate that `kind` is a pack-registered note kind.
594    ///
595    /// Returns `Ok(())` when no kinds are installed (bare runtime without packs).
596    /// Returns `InvalidInput` when kinds are installed and `kind` is not among them.
597    pub(crate) fn validate_note_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
598        let guard = self.valid_note_kinds.read().map_err(|_| {
599            crate::RuntimeError::Internal("note kind registry lock poisoned".into())
600        })?;
601        if guard.is_empty() {
602            return Ok(());
603        }
604        if guard.iter().any(|k| k == kind) {
605            Ok(())
606        } else {
607            Err(crate::RuntimeError::InvalidInput(format!(
608                "unknown note kind {kind:?}; valid: {}",
609                guard.join(", ")
610            )))
611        }
612    }
613
614    /// Snapshot of currently-installed pack edge rules.
615    pub(crate) fn pack_edge_rules(&self) -> Vec<EdgeEndpointRule> {
616        self.edge_rules
617            .read()
618            .map(|g| g.clone())
619            .unwrap_or_default()
620    }
621
622    /// Return the name of the default embedding model (empty string if none configured).
623    pub fn default_embedder_name(&self) -> &str {
624        self.default_embedder_name.as_ref()
625    }
626
627    /// Resolve a model name (or `None` for the default) to an `EmbeddingModel`.
628    ///
629    /// Returns `UnknownModel` if the name is not in the registry, or
630    /// `Unconfigured` if `None` is passed and no default model is set.
631    pub fn resolve_embedding_model(&self, name: Option<&str>) -> RuntimeResult<EmbeddingModel> {
632        let model = match name {
633            Some(raw) => parse_embedding_model_alias(raw)
634                .ok_or_else(|| crate::RuntimeError::UnknownModel(raw.to_string()))?,
635            None => self
636                .config
637                .embedding_model
638                .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?,
639        };
640        let key = model.to_string();
641        let contains = self
642            .embedder_registry
643            .read()
644            .map(|reg| reg.contains(&key))
645            .unwrap_or(false);
646        if contains {
647            Ok(model)
648        } else {
649            Err(crate::RuntimeError::UnknownModel(
650                name.unwrap_or_else(|| self.default_embedder_name())
651                    .to_string(),
652            ))
653        }
654    }
655
656    /// Names of all registered embedding models in this runtime.
657    ///
658    /// Includes both built-in lattice models and any custom embedders
659    /// registered by packs via [`register_embedder`](Self::register_embedder).
660    /// Useful for operations that must touch every model's storage (e.g.,
661    /// scoped vector deletion on note delete — codex High 2 (PR #407)).
662    /// The default model is included.
663    pub fn registered_embedding_model_names(&self) -> Vec<String> {
664        self.embedder_registry
665            .read()
666            .map(|reg| reg.names())
667            .unwrap_or_default()
668    }
669
670    /// Get the lazily-initialized embedding service for the named model.
671    ///
672    /// Accepts both built-in lattice model names (e.g. `"all-minilm-l6-v2"`,
673    /// `"paraphrase"`) and custom provider names registered via
674    /// [`register_embedder`](Self::register_embedder).
675    ///
676    /// For lattice model names, aliases (e.g. `"paraphrase"`) are resolved to
677    /// their canonical key before looking up the registry. For custom providers
678    /// the name must match exactly as supplied during registration.
679    ///
680    /// First call for any name loads the underlying service (cold start cost);
681    /// subsequent calls are cheap (registry caches the `Arc`).
682    pub async fn embedder(&self, name: &str) -> RuntimeResult<Arc<dyn EmbeddingService>> {
683        // Try to resolve as a lattice alias first (normalises "paraphrase" →
684        // "paraphrase-multilingual-minilm-l12-v2", etc.).  If that succeeds,
685        // use the canonical key; otherwise fall back to the literal name so
686        // custom providers registered with non-lattice names are reachable.
687        let canonical_key = match parse_embedding_model_alias(name) {
688            Some(model) => model.to_string(),
689            None => name.to_owned(),
690        };
691        // Clone the entry before releasing the lock so we don't hold a
692        // RwLockGuard across the async OnceCell initialisation (Send bound).
693        let entry = {
694            let registry = self.embedder_registry.read().map_err(|_| {
695                crate::RuntimeError::Internal("embedder registry lock poisoned".into())
696            })?;
697            registry
698                .get_entry(&canonical_key)
699                .ok_or_else(|| crate::RuntimeError::UnknownModel(name.to_string()))?
700        };
701        entry.resolve().await
702    }
703
704    /// Register a custom embedding provider with this runtime.
705    ///
706    /// The provider is added to the shared [`EmbedderRegistry`] so all clones
707    /// of this runtime see the new provider immediately. If a provider with the
708    /// same name already exists it is replaced (last-writer wins — see
709    /// [`EmbedderRegistry::register`] for the rationale).
710    ///
711    /// Packs should call this from [`PackRuntime::register_embedders`] (the
712    /// hook is invoked by the transport during pack initialisation, before the
713    /// first verb dispatch).
714    ///
715    /// [`EmbedderRegistry`]: crate::embedder_registry::EmbedderRegistry
716    pub fn register_embedder(
717        &self,
718        provider: impl crate::embedder_registry::EmbedderProvider + 'static,
719    ) {
720        if let Ok(mut registry) = self.embedder_registry.write() {
721            registry.register(provider);
722        } else {
723            tracing::warn!(
724                "embedder registry lock poisoned — embedder {} not registered",
725                std::any::type_name::<dyn crate::embedder_registry::EmbedderProvider>()
726            );
727        }
728    }
729
730    /// List registered embedding models via `SqlAccess`, routing through the
731    /// existing connection pool rather than opening a fresh `Connection` per call.
732    ///
733    /// Optionally filter by `engine_name`. Returns an empty vec when the
734    /// `_embedding_models` table does not yet exist (e.g. no migrations have run
735    /// or no models have been registered). All other SQL errors are propagated.
736    pub async fn list_embedding_models(
737        &self,
738        engine_filter: Option<&str>,
739    ) -> RuntimeResult<Vec<khive_db::EmbeddingModelRegistryRecord>> {
740        use khive_storage::{SqlStatement, SqlValue};
741
742        let (sql_text, params) = if let Some(engine) = engine_filter {
743            (
744                "SELECT engine_name, model_id, key_version, dim, status, \
745                 activated_at, superseded_at \
746                 FROM _embedding_models WHERE engine_name = ?1 \
747                 ORDER BY engine_name, activated_at IS NULL, activated_at"
748                    .to_string(),
749                vec![SqlValue::Text(engine.to_string())],
750            )
751        } else {
752            (
753                "SELECT engine_name, model_id, key_version, dim, status, \
754                 activated_at, superseded_at \
755                 FROM _embedding_models \
756                 ORDER BY engine_name, activated_at IS NULL, activated_at"
757                    .to_string(),
758                vec![],
759            )
760        };
761
762        let stmt = SqlStatement {
763            sql: sql_text,
764            params,
765            label: Some("list_embedding_models".into()),
766        };
767
768        let mut reader = self
769            .sql()
770            .reader()
771            .await
772            .map_err(crate::RuntimeError::Storage)?;
773
774        let rows = match reader.query_all(stmt).await {
775            Ok(rows) => rows,
776            Err(e) if e.to_string().contains("no such table: _embedding_models") => {
777                return Ok(Vec::new())
778            }
779            Err(e) => return Err(crate::RuntimeError::Storage(e)),
780        };
781
782        let mut records = Vec::with_capacity(rows.len());
783        for row in rows {
784            macro_rules! required_text {
785                ($col:expr) => {
786                    match row.get($col) {
787                        Some(SqlValue::Text(s)) => s.clone(),
788                        other => {
789                            tracing::warn!(column = $col, value = ?other, "skipping registry row: unexpected type");
790                            continue;
791                        }
792                    }
793                };
794            }
795            let engine_name = required_text!("engine_name");
796            let model_id = required_text!("model_id");
797            let key_version = required_text!("key_version");
798            let dimensions = match row.get("dim") {
799                Some(SqlValue::Integer(n)) => match u32::try_from(*n) {
800                    Ok(d) => d,
801                    Err(_) => {
802                        tracing::warn!(dim = n, "skipping registry row: dim out of u32 range");
803                        continue;
804                    }
805                },
806                other => {
807                    tracing::warn!(column = "dim", value = ?other, "skipping registry row: unexpected type");
808                    continue;
809                }
810            };
811            let status = required_text!("status");
812            let activated_at = match row.get("activated_at") {
813                Some(SqlValue::Integer(n)) => Some(*n),
814                _ => None,
815            };
816            let superseded_at = match row.get("superseded_at") {
817                Some(SqlValue::Integer(n)) => Some(*n),
818                _ => None,
819            };
820            records.push(khive_db::EmbeddingModelRegistryRecord {
821                engine_name,
822                model_id,
823                key_version,
824                dimensions,
825                status,
826                activated_at,
827                superseded_at,
828            });
829        }
830
831        Ok(records)
832    }
833}
834
835/// Sanitize an embedding model into a valid SQL table suffix.
836/// e.g. `bge-small-en-v1.5` -> `bge_small_en_v1_5`
837pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
838    sanitize_key(&model.to_string())
839}
840
841pub(crate) fn sanitize_key(s: &str) -> String {
842    s.chars()
843        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
844        .collect()
845}
846
847fn build_embedder_registry(
848    config: &RuntimeConfig,
849) -> (crate::embedder_registry::EmbedderRegistry, Arc<str>) {
850    use crate::embedder_registry::{EmbedderRegistry, LatticeEmbedderProvider};
851    let mut registry = EmbedderRegistry::new();
852    for model in configured_embedding_models(config) {
853        registry.register(LatticeEmbedderProvider::new(model));
854    }
855    let default_embedder_name = config
856        .embedding_model
857        .map(|model| Arc::<str>::from(model.to_string()))
858        .unwrap_or_else(|| Arc::<str>::from(""));
859    (registry, default_embedder_name)
860}
861
862fn configured_embedding_models(config: &RuntimeConfig) -> Vec<EmbeddingModel> {
863    let mut models = Vec::new();
864    if let Some(model) = config.embedding_model {
865        models.push(model);
866    }
867    models.extend(config.additional_embedding_models.iter().copied());
868    models.sort_by_key(|model| model.to_string());
869    models.dedup();
870    models
871}
872
873fn register_configured_embedding_models(
874    backend: &StorageBackend,
875    config: &RuntimeConfig,
876) -> RuntimeResult<()> {
877    for model in configured_embedding_models(config) {
878        backend.register_embedding_model(
879            &model.to_string(),
880            model.model_id(),
881            model.key_version(),
882            model.dimensions() as u32,
883        )?;
884    }
885    Ok(())
886}
887
888/// Build a `RuntimeConfig` from a parsed `KhiveConfig`.
889///
890/// For each `[[engines]]` entry:
891/// - The engine flagged `default = true` becomes `RuntimeConfig::embedding_model`.
892/// - All other engines become `RuntimeConfig::additional_embedding_models`.
893///
894/// Model name validity is checked here: any engine whose `model` field cannot
895/// be parsed via `parse_embedding_model_alias` is skipped with a warning.
896///
897/// If `khive_cfg.engines` is empty, the returned `RuntimeConfig` uses the
898/// env-var-derived defaults from `RuntimeConfig::default()`.
899///
900/// When both a config file and `KHIVE_EMBEDDING_MODEL` env var are present,
901/// the caller is responsible for emitting a warning that env vars are ignored.
902/// This function purely converts `KhiveConfig` to `RuntimeConfig` fields.
903pub fn runtime_config_from_khive_config(
904    khive_cfg: &crate::engine_config::KhiveConfig,
905    base: RuntimeConfig,
906) -> RuntimeConfig {
907    // Apply actor.id as default_namespace when present and valid.
908    // KhiveConfig::validate() guarantees that actor.id, when present, is a
909    // structurally valid Namespace — so the Err arm here is unreachable for
910    // any config that passed load(). A panic here signals a caller contract
911    // violation (passing an unvalidated config).
912    let default_namespace = match khive_cfg.actor.id.as_deref() {
913        Some(id) if !id.is_empty() => match Namespace::parse(id) {
914            Ok(ns) => {
915                tracing::debug!(actor_id = id, "actor.id from config sets default_namespace");
916                ns
917            }
918            Err(e) => {
919                panic!(
920                    "actor.id {id:?} passed validation but Namespace::parse failed: {e}; \
921                     this is a bug — KhiveConfig must be validated before calling \
922                     runtime_config_from_khive_config"
923                );
924            }
925        },
926        _ => base.default_namespace.clone(),
927    };
928
929    if khive_cfg.engines.is_empty() {
930        return RuntimeConfig {
931            default_namespace,
932            ..base
933        };
934    }
935
936    let mut embedding_model: Option<EmbeddingModel> = None;
937    let mut additional: Vec<EmbeddingModel> = Vec::new();
938
939    for engine in &khive_cfg.engines {
940        match parse_embedding_model_alias(&engine.model) {
941            Some(model) => {
942                if engine.default {
943                    embedding_model = Some(model);
944                } else {
945                    additional.push(model);
946                }
947            }
948            None => {
949                tracing::warn!(
950                    engine = %engine.name,
951                    model = %engine.model,
952                    "engine config: unknown model name; engine will be skipped"
953                );
954            }
955        }
956    }
957
958    RuntimeConfig {
959        embedding_model,
960        additional_embedding_models: additional,
961        default_namespace,
962        ..base
963    }
964}
965
966/// Parse a comma- or whitespace-separated list of embedding model names.
967fn parse_embedding_model_list(s: &str) -> Vec<EmbeddingModel> {
968    parse_pack_list(s)
969        .into_iter()
970        .filter_map(|raw| {
971            let parsed = parse_embedding_model_alias(&raw);
972            if parsed.is_none() && !raw.trim().is_empty() {
973                // Codex Medium (PR #407): silent filter_map masks operator typos. Warn loudly
974                // so misconfiguration surfaces at startup rather than as an UnknownModel error
975                // at request time. We do not fail startup — a partially valid list still
976                // produces a functional runtime — but the warning is unambiguous.
977                tracing::warn!(
978                    model = %raw,
979                    "KHIVE_ADDITIONAL_EMBEDDING_MODELS contains unknown model name; ignored. \
980                     Valid forms: short alias like 'paraphrase' or a fully-qualified key \
981                     from lattice_embed::EmbeddingModel::from_str."
982                );
983            }
984            parsed
985        })
986        .collect()
987}
988
989pub(crate) fn parse_embedding_model_alias(name: &str) -> Option<EmbeddingModel> {
990    let normalized = name.trim().to_ascii_lowercase().replace('_', "-");
991    match normalized.as_str() {
992        "paraphrase" => Some(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2),
993        _ => normalized.parse().ok(),
994    }
995}
996
997// INLINE TEST JUSTIFICATION: tests here cover KhiveRuntime construction helpers
998// (in-memory backend wiring, NamespaceToken::for_namespace) that are
999// pub(crate)-only and cannot be called from the integration test crate.
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    #[test]
1005    fn memory_runtime_creates_successfully() {
1006        let rt = KhiveRuntime::memory().expect("memory runtime should create");
1007        assert!(rt.config().db_path.is_none());
1008    }
1009
1010    #[test]
1011    fn file_runtime_creates_successfully() {
1012        let dir = tempfile::tempdir().unwrap();
1013        let path = dir.path().join("test.db");
1014        let config = RuntimeConfig {
1015            db_path: Some(path.clone()),
1016            default_namespace: Namespace::parse("test").unwrap(),
1017            embedding_model: None,
1018            additional_embedding_models: vec![],
1019            gate: Arc::new(AllowAllGate),
1020            packs: vec!["kg".to_string()],
1021            backend_id: BackendId::main(),
1022        };
1023        let rt = KhiveRuntime::new(config).expect("file runtime should create");
1024        assert!(path.exists());
1025        assert_eq!(rt.config().default_namespace.as_str(), "test");
1026    }
1027
1028    #[test]
1029    fn from_backend_uses_provided_backend() {
1030        let backend = Arc::new(StorageBackend::memory().expect("memory backend"));
1031        let config = RuntimeConfig {
1032            db_path: None,
1033            default_namespace: Namespace::local(),
1034            embedding_model: None,
1035            additional_embedding_models: vec![],
1036            gate: Arc::new(AllowAllGate),
1037            packs: vec!["kg".to_string()],
1038            backend_id: BackendId::new("lore"),
1039        };
1040        let rt = KhiveRuntime::from_backend(backend, config);
1041        assert_eq!(rt.backend_id().as_str(), "lore");
1042        assert!(rt.config().db_path.is_none());
1043    }
1044
1045    #[test]
1046    fn backend_id_defaults_to_main() {
1047        let rt = KhiveRuntime::memory().unwrap();
1048        assert_eq!(rt.backend_id().as_str(), BackendId::MAIN);
1049    }
1050
1051    #[test]
1052    fn store_accessors_return_ok() {
1053        let rt = KhiveRuntime::memory().unwrap();
1054        let tok = NamespaceToken::local();
1055        assert!(rt.entities(&tok).is_ok());
1056        assert!(rt.graph(&tok).is_ok());
1057        assert!(rt.notes(&tok).is_ok());
1058        assert!(rt.events(&tok).is_ok());
1059    }
1060
1061    #[test]
1062    fn vectors_returns_unconfigured_without_model() {
1063        let rt = KhiveRuntime::memory().unwrap();
1064        let tok = NamespaceToken::local();
1065        match rt.vectors(&tok) {
1066            Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
1067            Err(other) => panic!("expected Unconfigured, got {:?}", other),
1068            Ok(_) => panic!("expected Err, got Ok"),
1069        }
1070    }
1071
1072    #[test]
1073    fn vec_model_key_sanitizes_dots_and_dashes() {
1074        assert_eq!(
1075            vec_model_key(EmbeddingModel::BgeSmallEnV15),
1076            "bge_small_en_v1_5"
1077        );
1078        assert_eq!(
1079            vec_model_key(EmbeddingModel::BgeBaseEnV15),
1080            "bge_base_en_v1_5"
1081        );
1082        assert_eq!(
1083            vec_model_key(EmbeddingModel::AllMiniLmL6V2),
1084            "all_minilm_l6_v2"
1085        );
1086    }
1087
1088    #[test]
1089    fn default_config_uses_allow_all_gate() {
1090        let cfg = RuntimeConfig::default();
1091        assert_eq!(cfg.default_namespace.as_str(), "local");
1092        let _: GateRef = cfg.gate.clone();
1093    }
1094
1095    #[test]
1096    fn parse_pack_list_handles_comma_and_whitespace() {
1097        assert_eq!(parse_pack_list("kg"), vec!["kg".to_string()]);
1098        assert_eq!(
1099            parse_pack_list("kg,gtd"),
1100            vec!["kg".to_string(), "gtd".to_string()]
1101        );
1102        assert_eq!(
1103            parse_pack_list("  kg ,  gtd  "),
1104            vec!["kg".to_string(), "gtd".to_string()]
1105        );
1106        assert_eq!(
1107            parse_pack_list("kg gtd"),
1108            vec!["kg".to_string(), "gtd".to_string()]
1109        );
1110        assert_eq!(parse_pack_list(",,"), Vec::<String>::new());
1111        assert_eq!(parse_pack_list(""), Vec::<String>::new());
1112    }
1113
1114    #[test]
1115    fn default_config_packs_loads_all_production_packs() {
1116        let prior = std::env::var("KHIVE_PACKS").ok();
1117        // SAFETY: test function runs single-threaded; no other threads read or write KHIVE_PACKS.
1118        unsafe {
1119            std::env::remove_var("KHIVE_PACKS");
1120        }
1121        let cfg = RuntimeConfig::default();
1122        assert!(cfg.packs.contains(&"kg".to_string()));
1123        assert!(cfg.packs.contains(&"gtd".to_string()));
1124        assert!(cfg.packs.contains(&"memory".to_string()));
1125        assert!(cfg.packs.contains(&"brain".to_string()));
1126        assert!(cfg.packs.contains(&"comm".to_string()));
1127        assert!(cfg.packs.contains(&"schedule".to_string()));
1128        assert!(cfg.packs.contains(&"knowledge".to_string()));
1129        assert_eq!(cfg.packs.len(), 7);
1130        if let Some(v) = prior {
1131            // SAFETY: single-threaded test cleanup; restores KHIVE_PACKS to its prior value.
1132            unsafe {
1133                std::env::set_var("KHIVE_PACKS", v);
1134            }
1135        }
1136    }
1137
1138    #[test]
1139    fn default_config_uses_minilm_when_env_unset() {
1140        let prior = std::env::var("KHIVE_EMBEDDING_MODEL").ok();
1141        // SAFETY: tests are serial by default for env mutation here; if other tests
1142        // mutate this var, mark them with the same scope.
1143        unsafe {
1144            std::env::remove_var("KHIVE_EMBEDDING_MODEL");
1145        }
1146        let cfg = RuntimeConfig::default();
1147        assert_eq!(cfg.embedding_model, Some(EmbeddingModel::AllMiniLmL6V2));
1148        if let Some(v) = prior {
1149            // SAFETY: single-threaded test cleanup; restores KHIVE_EMBEDDING_MODEL to its prior value.
1150            unsafe {
1151                std::env::set_var("KHIVE_EMBEDDING_MODEL", v);
1152            }
1153        }
1154    }
1155
1156    // ---- Actor config tests ----
1157
1158    use crate::engine_config::{ActorConfig, KhiveConfig};
1159
1160    fn khive_cfg_with_actor(id: &str) -> KhiveConfig {
1161        KhiveConfig {
1162            engines: vec![],
1163            actor: ActorConfig {
1164                id: Some(id.to_string()),
1165                display_name: None,
1166            },
1167        }
1168    }
1169
1170    #[test]
1171    fn runtime_config_from_khive_config_applies_actor_id_as_default_namespace() {
1172        let base = RuntimeConfig {
1173            db_path: None,
1174            default_namespace: Namespace::local(),
1175            embedding_model: None,
1176            additional_embedding_models: vec![],
1177            gate: Arc::new(AllowAllGate),
1178            packs: vec!["kg".to_string()],
1179            backend_id: BackendId::main(),
1180        };
1181        let cfg = khive_cfg_with_actor("lambda:khive");
1182        let result = runtime_config_from_khive_config(&cfg, base);
1183        assert_eq!(result.default_namespace.as_str(), "lambda:khive");
1184    }
1185
1186    #[test]
1187    fn runtime_config_from_khive_config_empty_actor_id_keeps_base_namespace() {
1188        let base = RuntimeConfig {
1189            db_path: None,
1190            default_namespace: Namespace::parse("lambda:base").unwrap(),
1191            embedding_model: None,
1192            additional_embedding_models: vec![],
1193            gate: Arc::new(AllowAllGate),
1194            packs: vec!["kg".to_string()],
1195            backend_id: BackendId::main(),
1196        };
1197        let cfg = KhiveConfig {
1198            engines: vec![],
1199            actor: ActorConfig {
1200                id: Some(String::new()),
1201                display_name: None,
1202            },
1203        };
1204        let result = runtime_config_from_khive_config(&cfg, base);
1205        assert_eq!(
1206            result.default_namespace.as_str(),
1207            "lambda:base",
1208            "empty actor.id must not override base namespace"
1209        );
1210    }
1211
1212    #[test]
1213    fn runtime_config_from_khive_config_absent_actor_id_keeps_base_namespace() {
1214        let base = RuntimeConfig {
1215            db_path: None,
1216            default_namespace: Namespace::parse("lambda:base").unwrap(),
1217            embedding_model: None,
1218            additional_embedding_models: vec![],
1219            gate: Arc::new(AllowAllGate),
1220            packs: vec!["kg".to_string()],
1221            backend_id: BackendId::main(),
1222        };
1223        let cfg = KhiveConfig::default(); // no actor.id
1224        let result = runtime_config_from_khive_config(&cfg, base);
1225        assert_eq!(
1226            result.default_namespace.as_str(),
1227            "lambda:base",
1228            "absent actor.id must not override base namespace"
1229        );
1230    }
1231
1232    #[test]
1233    fn runtime_config_from_khive_config_actor_id_with_engines() {
1234        let base = RuntimeConfig {
1235            db_path: None,
1236            default_namespace: Namespace::local(),
1237            embedding_model: None,
1238            additional_embedding_models: vec![],
1239            gate: Arc::new(AllowAllGate),
1240            packs: vec!["kg".to_string()],
1241            backend_id: BackendId::main(),
1242        };
1243        let cfg = KhiveConfig {
1244            engines: vec![crate::engine_config::EngineConfig {
1245                name: "default".to_string(),
1246                model: "all-minilm-l6-v2".to_string(),
1247                default: true,
1248                fusion_weight: None,
1249                dims: None,
1250            }],
1251            actor: ActorConfig {
1252                id: Some("lambda:test".to_string()),
1253                display_name: None,
1254            },
1255        };
1256        let result = runtime_config_from_khive_config(&cfg, base);
1257        assert_eq!(result.default_namespace.as_str(), "lambda:test");
1258        assert!(result.embedding_model.is_some());
1259    }
1260
1261    // ---- list_embedding_models tests ----
1262
1263    #[tokio::test]
1264    async fn list_embedding_models_returns_empty_when_table_absent() {
1265        // A brand-new in-memory runtime has migrations applied, so _embedding_models
1266        // IS created. But with no rows inserted, the result must be empty.
1267        let rt = KhiveRuntime::memory().expect("memory runtime");
1268        let records = rt
1269            .list_embedding_models(None)
1270            .await
1271            .expect("list ok on empty table");
1272        assert!(records.is_empty());
1273    }
1274
1275    #[tokio::test]
1276    async fn list_embedding_models_returns_row_after_insert() {
1277        use khive_storage::{SqlStatement, SqlValue};
1278
1279        let rt = KhiveRuntime::memory().expect("memory runtime");
1280        let sql = rt.sql();
1281
1282        let now = 1_000_000i64;
1283        let id = uuid::Uuid::new_v4();
1284        let canonical_key = b"test_engine:test-model-v1:v1:384".to_vec();
1285
1286        let mut writer = sql.writer().await.expect("writer");
1287        writer
1288            .execute(SqlStatement {
1289                sql: "INSERT INTO _embedding_models \
1290                      (id, engine_name, model_id, key_version, dim, output_dim, status, \
1291                       activated_at, superseded_at, superseded_by, canonical_key, created_at) \
1292                      VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, NULL, NULL, ?8, ?9)"
1293                    .into(),
1294                params: vec![
1295                    SqlValue::Blob(id.as_bytes().to_vec()),
1296                    SqlValue::Text("test_engine".into()),
1297                    SqlValue::Text("test-model-v1".into()),
1298                    SqlValue::Text("v1".into()),
1299                    SqlValue::Integer(384),
1300                    SqlValue::Text("active".into()),
1301                    SqlValue::Integer(now),
1302                    SqlValue::Blob(canonical_key),
1303                    SqlValue::Integer(now),
1304                ],
1305                label: None,
1306            })
1307            .await
1308            .expect("insert row");
1309        drop(writer);
1310
1311        let records = rt.list_embedding_models(None).await.expect("list ok");
1312        assert_eq!(records.len(), 1);
1313        assert_eq!(records[0].engine_name, "test_engine");
1314        assert_eq!(records[0].model_id, "test-model-v1");
1315        assert_eq!(records[0].key_version, "v1");
1316        assert_eq!(records[0].dimensions, 384);
1317        assert_eq!(records[0].status, "active");
1318
1319        // engine filter — match
1320        let filtered = rt
1321            .list_embedding_models(Some("test_engine"))
1322            .await
1323            .expect("filter ok");
1324        assert_eq!(filtered.len(), 1);
1325
1326        // engine filter — no match
1327        let no_match = rt
1328            .list_embedding_models(Some("other_engine"))
1329            .await
1330            .expect("no-match ok");
1331        assert!(no_match.is_empty());
1332    }
1333}