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