Skip to main content

khive_runtime/
runtime.rs

1//! KhiveRuntime — composable handle to all storage capabilities.
2//!
3//! `RuntimeConfig`, `BackendId`, `NamespaceToken`, and embedding model helpers
4//! live in `super::config` and are re-exported from here.
5
6use std::sync::{Arc, RwLock};
7
8use khive_db::StorageBackend;
9#[cfg(test)]
10use khive_gate::AllowAllGate;
11use khive_gate::GateRequest;
12use khive_storage::{EntityStore, EventStore, GraphStore, NoteStore, SqlAccess};
13use khive_types::{EdgeEndpointRule, Namespace};
14use lattice_embed::{EmbeddingModel, EmbeddingService};
15
16use crate::config::{
17    build_embedder_registry, parse_embedding_model_alias, register_configured_embedding_models,
18    sanitize_key, vec_model_key,
19};
20use crate::error::{RuntimeError, RuntimeResult};
21
22/// Callback type for pack-installed entity-type validators.
23///
24/// Receives `(kind, entity_type)` and returns the normalised type string,
25/// or `RuntimeError::InvalidInput` if the type is not registered for that kind.
26/// When `entity_type` is `None`, the implementation must return `Ok(None)`.
27pub type EntityTypeValidatorFn =
28    Arc<dyn Fn(&str, Option<&str>) -> Result<Option<String>, RuntimeError> + Send + Sync>;
29
30/// Callback type for a pack-installed note-mutation hook.
31///
32/// Invoked by `update_note` (when the note's text/embedding actually
33/// changed) and `delete_note` (soft or hard) with `(note_kind, note_id)`,
34/// after the mutation has been durably applied. Returns a boxed future so
35/// the hook can await async cache-invalidation work (e.g.
36/// `khive-pack-memory`'s ANN warm-cache generation bump) without
37/// `khive-runtime` depending on any pack crate: dependencies point the
38/// other way, so the runtime exposes an extension point and the pack
39/// installs into it, same shape as `EntityTypeValidatorFn`, just async.
40pub type NoteMutationHookFn = Arc<
41    dyn Fn(String, uuid::Uuid) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
42        + Send
43        + Sync,
44>;
45
46pub use crate::config::{
47    assert_captured_db_anchor_consistent, assert_db_anchor_consistent, parse_pack_list,
48    resolve_db_anchor, resolve_project_actor_id, runtime_config_from_khive_config, BackendId,
49    NamespaceToken, RuntimeConfig,
50};
51
52// ---- KhiveRuntime ----
53
54/// Composable runtime handle used by the MCP server.
55///
56/// Wraps a `StorageBackend` and provides namespace-scoped accessor methods
57/// for each storage capability, plus a lazily-loaded embedder.
58#[derive(Clone)]
59pub struct KhiveRuntime {
60    backend: Arc<StorageBackend>,
61    /// When `Some`, holds the main backend so that `core()` can return a
62    /// main-bound runtime handle without constructing a new connection.
63    /// `None` when this runtime is already bound to the main backend.
64    core_backend: Option<Arc<StorageBackend>>,
65    config: RuntimeConfig,
66    /// Pack-extensible embedder registry.
67    ///
68    /// Shared across clones via `Arc<RwLock<_>>` so that
69    /// [`register_embedder`](Self::register_embedder) after clone is visible
70    /// to all handles. Built-in lattice models are pre-registered during
71    /// construction; packs may add more via [`PackRuntime::register_embedders`].
72    embedder_registry: Arc<std::sync::RwLock<crate::embedder_registry::EmbedderRegistry>>,
73    default_embedder_name: Arc<str>,
74    /// Pack-extensible edge endpoint rules. Shared across clones
75    /// via `Arc<RwLock<_>>`; installed once by the transport after the
76    /// `VerbRegistry` is built. Empty until installed
77    edge_rules: Arc<RwLock<Vec<EdgeEndpointRule>>>,
78    /// Pack-aggregated valid entity and note kind strings.
79    ///
80    /// Installed by the transport layer after building the `VerbRegistry`.
81    /// When non-empty, `create_entity`, `create_note_inner`, and `import_kg`
82    /// reject kinds not in these sets. When empty (no packs loaded, e.g.
83    /// bare runtime in unit tests), kind validation is skipped — the pack
84    /// handler layer is the primary enforcement point.
85    valid_entity_kinds: Arc<RwLock<Vec<String>>>,
86    valid_note_kinds: Arc<RwLock<Vec<String>>>,
87    /// Pack-installed entity-type validator.
88    ///
89    /// When `Some`, `create_many` calls this function to validate and normalise
90    /// each `(kind, entity_type)` pair before writing. When `None` (bare runtime
91    /// without packs), entity-type validation is skipped — the pack handler layer
92    /// is the primary enforcement point, same as for `valid_entity_kinds`.
93    entity_type_validator: Arc<RwLock<Option<EntityTypeValidatorFn>>>,
94    /// Pack-installed note-mutation hook.
95    ///
96    /// When `Some`, `update_note` (on text change) and `delete_note` (soft
97    /// or hard) call this after the mutation is durably applied, so a pack
98    /// that caches derived state keyed by note content (e.g. `khive-pack-memory`'s
99    /// warm ANN index) can invalidate/advance its own generation counter even
100    /// when the mutation arrived through a different pack's verb (e.g. KG's
101    /// `update`/`delete` on a `kind="memory"` note) that has no dependency on
102    /// the reacting pack. `None` when no pack installs one (bare runtime, or
103    /// no pack cares about note-mutation notifications) — the call becomes a
104    /// no-op check of an `Option`.
105    note_mutation_hook: Arc<RwLock<Option<NoteMutationHookFn>>>,
106    /// The config-resolved `BlobStore` (ADR-111 Amendment 2), installed by
107    /// the boot path (`khive-mcp`'s single- and multi-backend startup paths)
108    /// via [`install_blob_store`](Self::install_blob_store) once `khive.toml`'s
109    /// `[storage.blob]` selection has been resolved through
110    /// `khive_runtime::resolve_blob_store`. `None` until installed — every
111    /// constructor here defaults it unset rather than eagerly resolving a
112    /// filesystem default, because many callers (unit tests, `memory()`) use
113    /// an in-memory backend with no blob root configured at all.
114    blob_store: Arc<RwLock<Option<Arc<dyn khive_storage::BlobStore>>>>,
115}
116
117impl KhiveRuntime {
118    /// Create a new runtime with the given config.
119    ///
120    /// The config's `db_path` is used to open or create the SQLite backend.
121    /// For the preferred boot path in multi-backend deployments, use
122    /// [`from_backend`](Self::from_backend) instead.
123    pub fn new(config: RuntimeConfig) -> RuntimeResult<Self> {
124        let backend = match &config.db_path {
125            Some(path) => {
126                if let Some(parent) = path.parent() {
127                    std::fs::create_dir_all(parent).ok();
128                }
129                StorageBackend::sqlite(path)?
130            }
131            None => StorageBackend::memory()?,
132        };
133        // Migrations must run before any pack handler touches the DB; idempotent;
134        // failure aborts construction with a clear error.
135        {
136            let mut writer = backend.pool().try_writer()?;
137            khive_db::run_migrations(writer.conn_mut())?;
138        }
139        register_configured_embedding_models(&backend, &config)?;
140        let (registry, default_embedder_name) = build_embedder_registry(&config);
141        Ok(Self {
142            backend: Arc::new(backend),
143            core_backend: None,
144            config,
145            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
146            default_embedder_name,
147            edge_rules: Arc::new(RwLock::new(Vec::new())),
148            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
149            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
150            entity_type_validator: Arc::new(RwLock::new(None)),
151            note_mutation_hook: Arc::new(RwLock::new(None)),
152            blob_store: Arc::new(RwLock::new(None)),
153        })
154    }
155
156    /// Open a runtime for read-only inspection (no model registration, no DB creation).
157    ///
158    /// Runs migrations (idempotent) but skips `register_configured_embedding_models`,
159    /// so `engine list` / `engine status` cannot mutate the registry as a side effect.
160    /// Returns `None` when `db_path` is `None` and the default DB does not exist.
161    pub fn new_readonly(config: RuntimeConfig) -> RuntimeResult<Self> {
162        let backend = match &config.db_path {
163            Some(path) => StorageBackend::sqlite(path)?,
164            None => StorageBackend::memory()?,
165        };
166        {
167            let mut writer = backend.pool().try_writer()?;
168            khive_db::run_migrations(writer.conn_mut())?;
169        }
170        let (registry, default_embedder_name) = build_embedder_registry(&config);
171        Ok(Self {
172            backend: Arc::new(backend),
173            core_backend: None,
174            config,
175            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
176            default_embedder_name,
177            edge_rules: Arc::new(RwLock::new(Vec::new())),
178            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
179            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
180            entity_type_validator: Arc::new(RwLock::new(None)),
181            note_mutation_hook: Arc::new(RwLock::new(None)),
182            blob_store: Arc::new(RwLock::new(None)),
183        })
184    }
185
186    /// Construct a runtime from an already-opened backend.
187    ///
188    /// This is the preferred constructor for multi-backend deployments. The caller
189    /// (boot path in `kkernel` or `khive-mcp`) opens each backend from `khive.toml`,
190    /// then constructs a `KhiveRuntime` per pack using this method.
191    ///
192    /// The returned runtime has `db_path = None` and `embedding_model = None`; all
193    /// storage access is through the provided `backend`. Set `backend_id` and
194    /// `default_namespace` via the config builder pattern if non-defaults are needed.
195    pub fn from_backend(backend: Arc<StorageBackend>, config: RuntimeConfig) -> Self {
196        if let Err(err) = register_configured_embedding_models(&backend, &config) {
197            tracing::warn!(error = %err, "failed to register configured embedding models");
198        }
199        let (registry, default_embedder_name) = build_embedder_registry(&config);
200        Self {
201            backend,
202            core_backend: None,
203            config,
204            embedder_registry: Arc::new(std::sync::RwLock::new(registry)),
205            default_embedder_name,
206            edge_rules: Arc::new(RwLock::new(Vec::new())),
207            valid_entity_kinds: Arc::new(RwLock::new(Vec::new())),
208            valid_note_kinds: Arc::new(RwLock::new(Vec::new())),
209            entity_type_validator: Arc::new(RwLock::new(None)),
210            note_mutation_hook: Arc::new(RwLock::new(None)),
211            blob_store: Arc::new(RwLock::new(None)),
212        }
213    }
214
215    /// Wire this runtime as a secondary-backend runtime pointing at `core`.
216    ///
217    /// After this call, `self.core()` returns a handle to `core` rather than
218    /// cloning `self`. The caller (the boot path, not pack code) is responsible
219    /// for passing the correct main backend.
220    ///
221    /// Panics in debug builds if `self.config.backend_id == BackendId::MAIN`,
222    /// because the main runtime does not need a core pointer.
223    pub fn with_core_backend(mut self, core: Arc<StorageBackend>) -> Self {
224        debug_assert_ne!(
225            self.config.backend_id.as_str(),
226            BackendId::MAIN,
227            "with_core_backend must not be called on the main runtime"
228        );
229        self.core_backend = Some(core);
230        self
231    }
232
233    /// Return a runtime handle bound to the main (shared-graph) backend.
234    ///
235    /// When `self` is already the main runtime (`core_backend` is `None`),
236    /// this returns a clone of `self` — no new backend reference is acquired.
237    ///
238    /// When `self` is a secondary-backend runtime (`core_backend` is `Some`),
239    /// this returns a new `KhiveRuntime` backed by the main
240    /// `Arc<StorageBackend>` and sharing all registry state (`embedder_registry`,
241    /// `edge_rules`, `valid_entity_kinds`, `valid_note_kinds`,
242    /// `entity_type_validator`, `note_mutation_hook`) with `self`.
243    /// No database I/O occurs; no embedding models are reloaded.
244    ///
245    /// Use `core()` for notes and entities that must reside in the shared graph
246    /// so that `memory.recall`, cross-pack search, and `annotates` edges work.
247    /// Use `self` (or `self.sql()`) for pack-auxiliary bulk tables.
248    ///
249    /// Handlers that call `core()` more than once per request or loop should bind
250    /// `let core = self.core();` once and reuse it, since each call clones
251    /// `RuntimeConfig` (a heap-allocated struct containing `Vec<String>` fields).
252    pub fn core(&self) -> KhiveRuntime {
253        match &self.core_backend {
254            None => self.clone(),
255            Some(main_arc) => {
256                let mut core_config = self.config.clone();
257                core_config.backend_id = BackendId::main();
258                KhiveRuntime {
259                    backend: main_arc.clone(),
260                    core_backend: None,
261                    config: core_config,
262                    embedder_registry: self.embedder_registry.clone(),
263                    default_embedder_name: self.default_embedder_name.clone(),
264                    edge_rules: self.edge_rules.clone(),
265                    valid_entity_kinds: self.valid_entity_kinds.clone(),
266                    valid_note_kinds: self.valid_note_kinds.clone(),
267                    entity_type_validator: self.entity_type_validator.clone(),
268                    note_mutation_hook: self.note_mutation_hook.clone(),
269                    blob_store: self.blob_store.clone(),
270                }
271            }
272        }
273    }
274
275    /// Create an in-memory runtime (for tests and ephemeral use).
276    pub fn memory() -> RuntimeResult<Self> {
277        Self::new(RuntimeConfig {
278            db_path: None,
279            packs: vec!["kg".to_string()],
280            brain_profile: None,
281            actor_id: None,
282            ..RuntimeConfig::no_embeddings()
283        })
284    }
285
286    /// Return the [`BackendId`] for this runtime's backend.
287    ///
288    /// Used by `SubstrateCoordinator` in `kkernel`
289    /// to identify which backend owns a given node, and to detect cross-backend merges.
290    pub fn backend_id(&self) -> &BackendId {
291        &self.config.backend_id
292    }
293
294    /// Return the extra-visible namespaces assembled at config load.
295    ///
296    /// OSS dispatch uses this set to widen the default multi-record read scope
297    /// to `['local'] ∪ visible_namespaces`. Writes are unchanged: always
298    /// pinned to `'local'`. This set is also available as gate/cloud policy
299    /// input.
300    pub fn visible_namespaces(&self) -> &[Namespace] {
301        &self.config.visible_namespaces
302    }
303
304    /// Return a reference to the runtime config.
305    pub fn config(&self) -> &RuntimeConfig {
306        &self.config
307    }
308
309    /// Return a reference to the underlying storage backend.
310    pub fn backend(&self) -> &StorageBackend {
311        &self.backend
312    }
313
314    /// Return the directory containing the backend's database file, or `None`
315    /// for an in-memory backend.
316    pub fn backend_data_dir(&self) -> Option<std::path::PathBuf> {
317        self.backend.data_dir()
318    }
319
320    /// Root directory for this database's ANN segment tree (`<db-file>.ann/`
321    /// beside the file), or `None` for an in-memory backend. Scoped to the
322    /// database file itself so two databases sharing a parent directory can
323    /// never adopt each other's segments.
324    pub fn backend_ann_root(&self) -> Option<std::path::PathBuf> {
325        self.backend.ann_root()
326    }
327
328    // ---- Store accessors (token-scoped) ----
329
330    /// Get an EntityStore scoped to the token's namespace.
331    pub fn entities(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EntityStore>> {
332        Ok(self
333            .backend
334            .entities_for_namespace(token.namespace().as_str())?)
335    }
336
337    /// Get a GraphStore scoped to the token's namespace.
338    pub fn graph(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn GraphStore>> {
339        Ok(self
340            .backend
341            .graph_for_namespace(token.namespace().as_str())?)
342    }
343
344    /// Get a NoteStore scoped to the token's namespace.
345    pub fn notes(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn NoteStore>> {
346        Ok(self
347            .backend
348            .notes_for_namespace(token.namespace().as_str())?)
349    }
350
351    /// Get an EventStore scoped to the token's namespace.
352    pub fn events(&self, token: &NamespaceToken) -> RuntimeResult<Arc<dyn EventStore>> {
353        Ok(self
354            .backend
355            .events_for_namespace(token.namespace().as_str())?)
356    }
357
358    /// Get the raw SQL access capability (for ad-hoc queries).
359    pub fn sql(&self) -> Arc<dyn SqlAccess> {
360        self.backend.sql()
361    }
362
363    /// Get a VectorStore for the configured embedding model, scoped to the token's namespace.
364    ///
365    /// Returns `Unconfigured("embedding_model")` if no model is set.
366    pub fn vectors(
367        &self,
368        token: &NamespaceToken,
369    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
370        let model = self.resolve_embedding_model(None)?;
371        self.vectors_for_embedding_model(token, model)
372    }
373
374    /// Get a VectorStore for a specific named embedding model, scoped to the token's namespace.
375    ///
376    /// Accepts both built-in lattice model names/aliases and custom provider names
377    /// registered via [`register_embedder`](Self::register_embedder). Lattice names
378    /// are routed through the enum-backed path; custom provider names use the
379    /// provider's declared `dimensions()` directly so that the vector store key
380    /// is consistent with how vectors were written during `remember`/`recall`.
381    pub fn vectors_for_model(
382        &self,
383        token: &NamespaceToken,
384        model_name: &str,
385    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
386        if let Some(model) = parse_embedding_model_alias(model_name) {
387            // Only proceed via the lattice path if this model is actually in the
388            // registry; otherwise fall through to the custom-provider path.
389            let key = model.to_string();
390            let in_registry = self
391                .embedder_registry
392                .read()
393                .map(|reg| reg.contains(&key))
394                .unwrap_or(false);
395            if in_registry {
396                return self.vectors_for_embedding_model(token, model);
397            }
398        }
399        let dims = {
400            let registry = self.embedder_registry.read().map_err(|_| {
401                crate::RuntimeError::Internal("embedder registry lock poisoned".into())
402            })?;
403            registry
404                .get_provider(model_name)
405                .map(|p| p.dimensions())
406                .ok_or_else(|| crate::RuntimeError::UnknownModel(model_name.to_string()))?
407        };
408        let model_key = sanitize_key(model_name);
409        Ok(self.backend.vectors_for_namespace(
410            &model_key,
411            model_name,
412            dims,
413            token.namespace().as_str(),
414        )?)
415    }
416
417    /// Output dimensions for a named embedding model, resolved from the
418    /// embedder registry alone — no storage access. Mirrors
419    /// [`vectors_for_model`](Self::vectors_for_model)'s resolution order:
420    /// lattice aliases route through the enum when registered, otherwise the
421    /// custom provider's declared `dimensions()`. `None` when no such model
422    /// is registered.
423    pub fn embedder_dimensions(&self, model_name: &str) -> Option<usize> {
424        if let Some(model) = parse_embedding_model_alias(model_name) {
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 Some(model.dimensions());
433            }
434        }
435        self.embedder_registry
436            .read()
437            .ok()?
438            .get_provider(model_name)
439            .map(|p| p.dimensions())
440    }
441
442    fn vectors_for_embedding_model(
443        &self,
444        token: &NamespaceToken,
445        model: EmbeddingModel,
446    ) -> RuntimeResult<Arc<dyn khive_storage::VectorStore>> {
447        Ok(self.backend.vectors_for_namespace(
448            &vec_model_key(model),
449            &model.to_string(),
450            model.dimensions(),
451            token.namespace().as_str(),
452        )?)
453    }
454
455    /// Get a TextSearch index for the entity corpus (single shared table).
456    pub fn text(
457        &self,
458        token: &NamespaceToken,
459    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
460        let _ = token;
461        Ok(self.backend.text("entities")?)
462    }
463
464    /// Get a TextSearch index for the notes corpus (single shared table).
465    pub fn text_for_notes(
466        &self,
467        token: &NamespaceToken,
468    ) -> RuntimeResult<Arc<dyn khive_storage::TextSearch>> {
469        let _ = token;
470        Ok(self.backend.text("notes")?)
471    }
472
473    /// Mint an authorization token for the given namespace.
474    ///
475    /// Consults the configured [`crate::Gate`] before minting. With the default
476    /// `AllowAllGate` this always succeeds. When a real policy-backed gate is
477    /// installed, this method enforces it and returns `PermissionDenied` on
478    /// denial.
479    ///
480    /// The returned token's read visibility set defaults to `[ns]` — identical
481    /// to the pre-visibility-set behaviour. Use [`Self::authorize_with_visibility`]
482    /// to mint a token that can read additional namespaces.
483    ///
484    /// When `actor_id` is configured in `RuntimeConfig`, the token carries that
485    /// actor label so that `comm.inbox` filters by `to_actor`. When
486    /// unconfigured, the token carries `ActorRef::anonymous()` and inbox falls
487    /// back to party-line behavior.
488    pub fn authorize(&self, ns: Namespace) -> RuntimeResult<NamespaceToken> {
489        let actor = crate::actor_identity::resolve_actor(self.config.actor_id.as_deref());
490        let req = GateRequest::new(
491            actor.clone(),
492            ns.clone(),
493            "authorize",
494            serde_json::Value::Null,
495        );
496        match self.config.gate.check(&req) {
497            Ok(ref decision) if decision.is_allow() => {
498                if let khive_gate::GateDecision::Allow { ref obligations } = decision {
499                    if !obligations.is_empty() {
500                        tracing::debug!(
501                            namespace = %ns.as_str(),
502                            "authorize: obligations={:?}",
503                            obligations
504                        );
505                    }
506                }
507                Ok(NamespaceToken::mint_authorized(ns, actor))
508            }
509            Ok(khive_gate::GateDecision::Deny { reason }) => {
510                Err(crate::RuntimeError::PermissionDenied {
511                    verb: "authorize".to_string(),
512                    reason,
513                })
514            }
515            Ok(_) => Err(crate::RuntimeError::PermissionDenied {
516                verb: "authorize".to_string(),
517                reason: "gate denied".to_string(),
518            }),
519            Err(e) => Err(crate::RuntimeError::Internal(format!("gate error: {e}"))),
520        }
521    }
522
523    /// Mint an authorization token with an explicit read-visibility set.
524    ///
525    /// `primary` is the **write namespace** — all records created via the
526    /// returned token land there. `extra_visible` lists additional namespaces
527    /// the token may read. The primary is always included in the visible set
528    /// regardless of `extra_visible`.
529    ///
530    /// Usage (lambda:leo reading both leo and khive namespaces):
531    /// ```rust,ignore
532    /// let tok = rt.authorize_with_visibility(
533    ///     Namespace::parse("lambda:leo").unwrap(),
534    ///     vec![Namespace::parse("lambda:khive").unwrap()],
535    /// )?;
536    /// ```
537    pub fn authorize_with_visibility(
538        &self,
539        primary: Namespace,
540        extra_visible: Vec<Namespace>,
541    ) -> RuntimeResult<NamespaceToken> {
542        let actor = crate::actor_identity::resolve_actor(self.config.actor_id.as_deref());
543        let req = GateRequest::new(
544            actor.clone(),
545            primary.clone(),
546            "authorize",
547            serde_json::Value::Null,
548        );
549        match self.config.gate.check(&req) {
550            Ok(ref decision) if decision.is_allow() => {
551                if let khive_gate::GateDecision::Allow { ref obligations } = decision {
552                    if !obligations.is_empty() {
553                        tracing::debug!(
554                            namespace = %primary.as_str(),
555                            "authorize_with_visibility: obligations={:?}",
556                            obligations
557                        );
558                    }
559                }
560                Ok(NamespaceToken::mint_with_visibility(
561                    primary,
562                    extra_visible,
563                    actor,
564                ))
565            }
566            Ok(khive_gate::GateDecision::Deny { reason }) => {
567                Err(crate::RuntimeError::PermissionDenied {
568                    verb: "authorize".to_string(),
569                    reason,
570                })
571            }
572            Ok(_) => Err(crate::RuntimeError::PermissionDenied {
573                verb: "authorize".to_string(),
574                reason: "gate denied".to_string(),
575            }),
576            Err(e) => Err(crate::RuntimeError::Internal(format!("gate error: {e}"))),
577        }
578    }
579
580    /// Install the pack-aggregated edge endpoint rules.
581    ///
582    /// Called by the transport layer after the `VerbRegistry` is built so
583    /// that runtime-layer edge validation can consult pack rules. Idempotent:
584    /// later calls overwrite the previous rule set.
585    pub fn install_edge_rules(&self, rules: Vec<EdgeEndpointRule>) {
586        if let Ok(mut guard) = self.edge_rules.write() {
587            *guard = rules;
588        }
589    }
590
591    /// Install the config-resolved `BlobStore` (ADR-111 Amendment 2).
592    ///
593    /// Called by the boot path (`khive-mcp`'s single- and multi-backend
594    /// startup paths, `crates/khive-mcp/src/serve.rs`) once
595    /// `khive_runtime::resolve_blob_store` has selected the backend
596    /// `khive.toml`'s `[storage.blob]` section requests. Idempotent: a later
597    /// call overwrites the previous handle.
598    pub fn install_blob_store(&self, store: Arc<dyn khive_storage::BlobStore>) {
599        if let Ok(mut guard) = self.blob_store.write() {
600            *guard = Some(store);
601        }
602    }
603
604    /// Return the installed `BlobStore`, if the boot path resolved and
605    /// installed one. `None` when no `[storage.blob]` selection was ever
606    /// installed — e.g. a bare/test runtime constructed without going
607    /// through the `khive-mcp` boot path.
608    pub fn blob_store(&self) -> Option<Arc<dyn khive_storage::BlobStore>> {
609        self.blob_store.read().ok().and_then(|guard| guard.clone())
610    }
611
612    /// Install the pack-aggregated valid entity and note kinds.
613    ///
614    /// Called by the transport layer after the `VerbRegistry` is built so that
615    /// runtime-layer entity/note creation and import validate kind strings against
616    /// the merged pack vocabulary. Idempotent: later calls overwrite previous sets.
617    ///
618    /// When no kinds are installed (empty lists), kind validation is skipped at
619    /// the runtime layer. The pack handler layer remains the primary enforcement
620    /// point; this provides defense-in-depth for direct Rust callers and import.
621    pub fn install_kind_registry(&self, entity_kinds: Vec<String>, note_kinds: Vec<String>) {
622        if let Ok(mut guard) = self.valid_entity_kinds.write() {
623            *guard = entity_kinds;
624        }
625        if let Ok(mut guard) = self.valid_note_kinds.write() {
626            *guard = note_kinds;
627        }
628    }
629
630    /// Validate that `kind` is a pack-registered entity kind.
631    ///
632    /// Returns `Ok(())` when no kinds are installed (bare runtime without packs).
633    /// Returns `InvalidInput` when kinds are installed and `kind` is not among them.
634    pub(crate) fn validate_entity_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
635        let guard = self.valid_entity_kinds.read().map_err(|_| {
636            crate::RuntimeError::Internal("entity kind registry lock poisoned".into())
637        })?;
638        if guard.is_empty() {
639            return Ok(());
640        }
641        if guard.iter().any(|k| k == kind) {
642            Ok(())
643        } else {
644            Err(crate::RuntimeError::InvalidInput(format!(
645                "unknown entity kind {kind:?}; valid: {}",
646                guard.join(", ")
647            )))
648        }
649    }
650
651    /// Validate that `kind` is a pack-registered note kind.
652    ///
653    /// Returns `Ok(())` when no kinds are installed (bare runtime without packs).
654    /// Returns `InvalidInput` when kinds are installed and `kind` is not among them.
655    pub(crate) fn validate_note_kind(&self, kind: &str) -> crate::RuntimeResult<()> {
656        let guard = self.valid_note_kinds.read().map_err(|_| {
657            crate::RuntimeError::Internal("note kind registry lock poisoned".into())
658        })?;
659        if guard.is_empty() {
660            return Ok(());
661        }
662        if guard.iter().any(|k| k == kind) {
663            Ok(())
664        } else {
665            Err(crate::RuntimeError::InvalidInput(format!(
666                "unknown note kind {kind:?}; valid: {}",
667                guard.join(", ")
668            )))
669        }
670    }
671
672    /// Install a pack-supplied entity-type validator.
673    ///
674    /// Called by the `KgPack` during registration so that `create_many` can validate
675    /// `entity_type` values at the runtime layer, closing the hole where direct Rust
676    /// callers bypass the handler-layer `validate_entity_type` check.
677    ///
678    /// The callback receives `(kind, entity_type)` and returns the normalised type
679    /// string, or `RuntimeError::InvalidInput` if the type is not registered for that
680    /// kind. Passing `entity_type = None` must return `Ok(None)`.
681    pub fn install_entity_type_validator(&self, f: EntityTypeValidatorFn) {
682        if let Ok(mut guard) = self.entity_type_validator.write() {
683            *guard = Some(f);
684        }
685    }
686
687    /// Validate and normalise `entity_type` through the pack-installed validator.
688    ///
689    /// Returns `Ok(entity_type)` when no validator is installed (bare runtime).
690    /// Returns `InvalidInput` when a validator is installed and rejects the type.
691    pub(crate) fn validate_entity_type_for_kind(
692        &self,
693        kind: &str,
694        entity_type: Option<&str>,
695    ) -> crate::RuntimeResult<Option<String>> {
696        let guard = self.entity_type_validator.read().map_err(|_| {
697            crate::RuntimeError::Internal("entity type validator lock poisoned".into())
698        })?;
699        match guard.as_ref() {
700            None => Ok(entity_type.map(str::to_string)),
701            Some(validate) => validate(kind, entity_type),
702        }
703    }
704
705    /// Install a pack-owned note-mutation hook.
706    ///
707    /// Overwrites any previously-installed hook, same single-slot semantics
708    /// as [`install_entity_type_validator`](Self::install_entity_type_validator).
709    /// In practice only one pack (`khive-pack-memory`) installs one today;
710    /// if a second pack ever needs this, the slot should be widened to a
711    /// `Vec` at that point rather than silently overwritten.
712    pub fn install_note_mutation_hook(&self, f: NoteMutationHookFn) {
713        if let Ok(mut guard) = self.note_mutation_hook.write() {
714            *guard = Some(f);
715        }
716    }
717
718    /// Invoke the pack-installed note-mutation hook, if any.
719    ///
720    /// `kind` is the note's `kind` string (e.g. `"memory"`); `id` is the
721    /// note's UUID. No-op when no hook is installed (bare runtime, or no
722    /// pack cares). Errors inside the hook are the hook's own concern to
723    /// handle/log — this call site cannot propagate a failure without
724    /// changing `update_note`/`delete_note`'s already-committed success
725    /// return value.
726    pub(crate) async fn fire_note_mutation_hook(&self, kind: &str, id: uuid::Uuid) {
727        let hook = self
728            .note_mutation_hook
729            .read()
730            .ok()
731            .and_then(|guard| guard.clone());
732        if let Some(hook) = hook {
733            hook(kind.to_string(), id).await;
734        }
735    }
736
737    /// Snapshot of currently-installed pack edge rules.
738    ///
739    /// This is the same composed rule set `validate_edge_relation_endpoints`
740    /// consults via `pack_rule_allows` when accepting/rejecting an edge. Public
741    /// so pack-layer error-hint code (e.g. `khive-pack-kg`'s
742    /// `valid_relations_for_entity_pair`) can derive hints from the exact
743    /// source the validator uses, rather than maintaining a separate
744    /// hand-authored table that can drift out of sync.
745    pub fn pack_edge_rules(&self) -> Vec<EdgeEndpointRule> {
746        self.edge_rules
747            .read()
748            .map(|g| g.clone())
749            .unwrap_or_default()
750    }
751
752    /// Return the name of the default embedding model (empty string if none configured).
753    pub fn default_embedder_name(&self) -> &str {
754        self.default_embedder_name.as_ref()
755    }
756
757    /// Resolve a model name (or `None` for the default) to an `EmbeddingModel`.
758    ///
759    /// Returns `UnknownModel` if the name is not in the registry, or
760    /// `Unconfigured` if `None` is passed and no default model is set.
761    pub fn resolve_embedding_model(&self, name: Option<&str>) -> RuntimeResult<EmbeddingModel> {
762        let model = match name {
763            Some(raw) => parse_embedding_model_alias(raw)
764                .ok_or_else(|| crate::RuntimeError::UnknownModel(raw.to_string()))?,
765            None => self
766                .config
767                .embedding_model
768                .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?,
769        };
770        let key = model.to_string();
771        let contains = self
772            .embedder_registry
773            .read()
774            .map(|reg| reg.contains(&key))
775            .unwrap_or(false);
776        if contains {
777            Ok(model)
778        } else {
779            Err(crate::RuntimeError::UnknownModel(
780                name.unwrap_or_else(|| self.default_embedder_name())
781                    .to_string(),
782            ))
783        }
784    }
785
786    /// Names of all registered embedding models in this runtime.
787    ///
788    /// Includes both built-in lattice models and any custom embedders
789    /// registered by packs via [`register_embedder`](Self::register_embedder).
790    /// Useful for operations that must touch every model's storage (e.g.,
791    /// scoped vector deletion on note delete). The default model is included.
792    pub fn registered_embedding_model_names(&self) -> Vec<String> {
793        self.embedder_registry
794            .read()
795            .map(|reg| reg.names())
796            .unwrap_or_default()
797    }
798
799    /// Get the lazily-initialized embedding service for the named model.
800    ///
801    /// Accepts both built-in lattice model names (e.g. `"all-minilm-l6-v2"`,
802    /// `"paraphrase"`) and custom provider names registered via
803    /// [`register_embedder`](Self::register_embedder).
804    ///
805    /// For lattice model names, aliases (e.g. `"paraphrase"`) are resolved to
806    /// their canonical key before looking up the registry. For custom providers
807    /// the name must match exactly as supplied during registration.
808    ///
809    /// First call for any name loads the underlying service (cold start cost);
810    /// subsequent calls are cheap (registry caches the `Arc`).
811    pub async fn embedder(&self, name: &str) -> RuntimeResult<Arc<dyn EmbeddingService>> {
812        // Fall back to the literal name (not the alias table) so custom
813        // providers registered with non-lattice names stay reachable.
814        let canonical_key = match parse_embedding_model_alias(name) {
815            Some(model) => model.to_string(),
816            None => name.to_owned(),
817        };
818        // Clone the entry so we don't hold the RwLockGuard across the
819        // async OnceCell initialisation (Send bound).
820        let entry = {
821            let registry = self.embedder_registry.read().map_err(|_| {
822                crate::RuntimeError::Internal("embedder registry lock poisoned".into())
823            })?;
824            registry
825                .get_entry(&canonical_key)
826                .ok_or_else(|| crate::RuntimeError::UnknownModel(name.to_string()))?
827        };
828        entry.resolve().await
829    }
830
831    /// Register a custom embedding provider with this runtime.
832    ///
833    /// The provider is added to the shared [`EmbedderRegistry`] so all clones
834    /// of this runtime see the new provider immediately. If a provider with the
835    /// same name already exists it is replaced (last-writer wins — see
836    /// [`crate::EmbedderRegistry::register`] for the rationale).
837    ///
838    /// Packs should call this from [`crate::PackRuntime::register_embedders`] (the
839    /// hook is invoked by the transport during pack initialisation, before the
840    /// first verb dispatch).
841    ///
842    /// [`EmbedderRegistry`]: crate::embedder_registry::EmbedderRegistry
843    pub fn register_embedder(
844        &self,
845        provider: impl crate::embedder_registry::EmbedderProvider + 'static,
846    ) {
847        if let Ok(mut registry) = self.embedder_registry.write() {
848            registry.register(provider);
849        } else {
850            tracing::warn!(
851                "embedder registry lock poisoned — embedder {} not registered",
852                std::any::type_name::<dyn crate::embedder_registry::EmbedderProvider>()
853            );
854        }
855    }
856
857    /// List registered embedding models via `SqlAccess`, routing through the
858    /// existing connection pool rather than opening a fresh `Connection` per call.
859    ///
860    /// Optionally filter by `engine_name`. Returns an empty vec when the
861    /// `_embedding_models` table does not yet exist (e.g. no migrations have run
862    /// or no models have been registered). All other SQL errors are propagated.
863    pub async fn list_embedding_models(
864        &self,
865        engine_filter: Option<&str>,
866    ) -> RuntimeResult<Vec<khive_db::EmbeddingModelRegistryRecord>> {
867        use khive_storage::{SqlStatement, SqlValue};
868
869        let (sql_text, params) = if let Some(engine) = engine_filter {
870            (
871                "SELECT engine_name, model_id, key_version, dim, status, \
872                 activated_at, superseded_at \
873                 FROM _embedding_models WHERE engine_name = ?1 \
874                 ORDER BY engine_name, activated_at IS NULL, activated_at"
875                    .to_string(),
876                vec![SqlValue::Text(engine.to_string())],
877            )
878        } else {
879            (
880                "SELECT engine_name, model_id, key_version, dim, status, \
881                 activated_at, superseded_at \
882                 FROM _embedding_models \
883                 ORDER BY engine_name, activated_at IS NULL, activated_at"
884                    .to_string(),
885                vec![],
886            )
887        };
888
889        let stmt = SqlStatement {
890            sql: sql_text,
891            params,
892            label: Some("list_embedding_models".into()),
893        };
894
895        let mut reader = self
896            .sql()
897            .reader()
898            .await
899            .map_err(crate::RuntimeError::Storage)?;
900
901        let rows = match reader.query_all(stmt).await {
902            Ok(rows) => rows,
903            Err(e) if e.to_string().contains("no such table: _embedding_models") => {
904                return Ok(Vec::new())
905            }
906            Err(e) => return Err(crate::RuntimeError::Storage(e)),
907        };
908
909        let mut records = Vec::with_capacity(rows.len());
910        for row in rows {
911            macro_rules! required_text {
912                ($col:expr) => {
913                    match row.get($col) {
914                        Some(SqlValue::Text(s)) => s.clone(),
915                        other => {
916                            tracing::warn!(column = $col, value = ?other, "skipping registry row: unexpected type");
917                            continue;
918                        }
919                    }
920                };
921            }
922            let engine_name = required_text!("engine_name");
923            let model_id = required_text!("model_id");
924            let key_version = required_text!("key_version");
925            let dimensions = match row.get("dim") {
926                Some(SqlValue::Integer(n)) => match u32::try_from(*n) {
927                    Ok(d) => d,
928                    Err(_) => {
929                        tracing::warn!(dim = n, "skipping registry row: dim out of u32 range");
930                        continue;
931                    }
932                },
933                other => {
934                    tracing::warn!(column = "dim", value = ?other, "skipping registry row: unexpected type");
935                    continue;
936                }
937            };
938            let status = required_text!("status");
939            let activated_at = match row.get("activated_at") {
940                Some(SqlValue::Integer(n)) => Some(*n),
941                _ => None,
942            };
943            let superseded_at = match row.get("superseded_at") {
944                Some(SqlValue::Integer(n)) => Some(*n),
945                _ => None,
946            };
947            records.push(khive_db::EmbeddingModelRegistryRecord {
948                engine_name,
949                model_id,
950                key_version,
951                dimensions,
952                status,
953                activated_at,
954                superseded_at,
955            });
956        }
957
958        Ok(records)
959    }
960}
961
962// INLINE TEST JUSTIFICATION: tests here cover KhiveRuntime construction helpers
963// (in-memory backend wiring, NamespaceToken::for_namespace) that are
964// pub(crate)-only and cannot be called from the integration test crate.
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use khive_gate::GateRef;
969    use serial_test::serial;
970
971    #[test]
972    fn memory_runtime_creates_successfully() {
973        let rt = KhiveRuntime::memory().expect("memory runtime should create");
974        assert!(rt.config().db_path.is_none());
975    }
976
977    #[test]
978    fn backend_data_dir_returns_none_for_memory_backend() {
979        let rt = KhiveRuntime::memory().expect("memory runtime");
980        assert!(rt.backend_data_dir().is_none());
981    }
982
983    #[test]
984    fn backend_data_dir_returns_parent_dir_for_file_backend() {
985        let dir = tempfile::tempdir().unwrap();
986        let path = dir.path().join("test.db");
987        let config = RuntimeConfig {
988            git_write: Default::default(),
989            db_path: Some(path),
990            default_namespace: Namespace::local(),
991            embedding_model: None,
992            additional_embedding_models: vec![],
993            gate: Arc::new(AllowAllGate),
994            packs: vec!["kg".to_string()],
995            backend_id: BackendId::main(),
996            brain_profile: None,
997            visible_namespaces: vec![],
998            allowed_outbound_namespaces: vec![],
999            actor_id: None,
1000        };
1001        let rt = KhiveRuntime::new(config).expect("file runtime");
1002        let data_dir = rt
1003            .backend_data_dir()
1004            .expect("file backend must return Some");
1005        assert_eq!(data_dir, dir.path());
1006    }
1007
1008    #[test]
1009    fn backend_data_dir_returns_none_for_from_backend_with_memory() {
1010        let backend = Arc::new(StorageBackend::memory().expect("memory backend"));
1011        let config = RuntimeConfig {
1012            git_write: Default::default(),
1013            db_path: None,
1014            default_namespace: Namespace::local(),
1015            embedding_model: None,
1016            additional_embedding_models: vec![],
1017            gate: Arc::new(AllowAllGate),
1018            packs: vec!["kg".to_string()],
1019            backend_id: BackendId::main(),
1020            brain_profile: None,
1021            visible_namespaces: vec![],
1022            allowed_outbound_namespaces: vec![],
1023            actor_id: None,
1024        };
1025        let rt = KhiveRuntime::from_backend(backend, config);
1026        assert!(rt.backend_data_dir().is_none());
1027    }
1028
1029    #[test]
1030    fn file_runtime_creates_successfully() {
1031        let dir = tempfile::tempdir().unwrap();
1032        let path = dir.path().join("test.db");
1033        let config = RuntimeConfig {
1034            git_write: Default::default(),
1035            db_path: Some(path.clone()),
1036            default_namespace: Namespace::parse("test").unwrap(),
1037            embedding_model: None,
1038            additional_embedding_models: vec![],
1039            gate: Arc::new(AllowAllGate),
1040            packs: vec!["kg".to_string()],
1041            backend_id: BackendId::main(),
1042            brain_profile: None,
1043            visible_namespaces: vec![],
1044            allowed_outbound_namespaces: vec![],
1045            actor_id: None,
1046        };
1047        let rt = KhiveRuntime::new(config).expect("file runtime should create");
1048        assert!(path.exists());
1049        assert_eq!(rt.config().default_namespace.as_str(), "test");
1050    }
1051
1052    #[test]
1053    fn from_backend_uses_provided_backend() {
1054        let backend = Arc::new(StorageBackend::memory().expect("memory backend"));
1055        let config = RuntimeConfig {
1056            git_write: Default::default(),
1057            db_path: None,
1058            default_namespace: Namespace::local(),
1059            embedding_model: None,
1060            additional_embedding_models: vec![],
1061            gate: Arc::new(AllowAllGate),
1062            packs: vec!["kg".to_string()],
1063            backend_id: BackendId::new("lore"),
1064            brain_profile: None,
1065            visible_namespaces: vec![],
1066            allowed_outbound_namespaces: vec![],
1067            actor_id: None,
1068        };
1069        let rt = KhiveRuntime::from_backend(backend, config);
1070        assert_eq!(rt.backend_id().as_str(), "lore");
1071        assert!(rt.config().db_path.is_none());
1072    }
1073
1074    #[test]
1075    fn backend_id_defaults_to_main() {
1076        let rt = KhiveRuntime::memory().unwrap();
1077        assert_eq!(rt.backend_id().as_str(), BackendId::MAIN);
1078    }
1079
1080    #[test]
1081    fn store_accessors_return_ok() {
1082        let rt = KhiveRuntime::memory().unwrap();
1083        let tok = NamespaceToken::local();
1084        assert!(rt.entities(&tok).is_ok());
1085        assert!(rt.graph(&tok).is_ok());
1086        assert!(rt.notes(&tok).is_ok());
1087        assert!(rt.events(&tok).is_ok());
1088    }
1089
1090    #[test]
1091    fn vectors_returns_unconfigured_without_model() {
1092        let rt = KhiveRuntime::memory().unwrap();
1093        let tok = NamespaceToken::local();
1094        match rt.vectors(&tok) {
1095            Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
1096            Err(other) => panic!("expected Unconfigured, got {:?}", other),
1097            Ok(_) => panic!("expected Err, got Ok"),
1098        }
1099    }
1100
1101    #[test]
1102    fn vec_model_key_sanitizes_dots_and_dashes() {
1103        assert_eq!(
1104            vec_model_key(EmbeddingModel::BgeSmallEnV15),
1105            "bge_small_en_v1_5"
1106        );
1107        assert_eq!(
1108            vec_model_key(EmbeddingModel::BgeBaseEnV15),
1109            "bge_base_en_v1_5"
1110        );
1111        assert_eq!(
1112            vec_model_key(EmbeddingModel::AllMiniLmL6V2),
1113            "all_minilm_l6_v2"
1114        );
1115    }
1116
1117    #[test]
1118    fn default_config_uses_allow_all_gate() {
1119        let cfg = RuntimeConfig::default();
1120        assert_eq!(cfg.default_namespace.as_str(), "local");
1121        let _: GateRef = cfg.gate.clone();
1122    }
1123
1124    #[test]
1125    fn parse_pack_list_handles_comma_and_whitespace() {
1126        assert_eq!(parse_pack_list("kg"), vec!["kg".to_string()]);
1127        assert_eq!(
1128            parse_pack_list("kg,gtd"),
1129            vec!["kg".to_string(), "gtd".to_string()]
1130        );
1131        assert_eq!(
1132            parse_pack_list("  kg ,  gtd  "),
1133            vec!["kg".to_string(), "gtd".to_string()]
1134        );
1135        assert_eq!(
1136            parse_pack_list("kg gtd"),
1137            vec!["kg".to_string(), "gtd".to_string()]
1138        );
1139        assert_eq!(parse_pack_list(",,"), Vec::<String>::new());
1140        assert_eq!(parse_pack_list(""), Vec::<String>::new());
1141    }
1142
1143    #[test]
1144    fn default_config_packs_loads_kg_only() {
1145        let prior = std::env::var("KHIVE_PACKS").ok();
1146        // SAFETY: test function runs single-threaded; no other threads read or write KHIVE_PACKS.
1147        unsafe {
1148            std::env::remove_var("KHIVE_PACKS");
1149        }
1150        // The open-source distribution ships a single production pack: kg.
1151        // Extension packs are commercially licensed, distributed separately,
1152        // and opt in via KHIVE_PACKS / --pack against a binary that links them.
1153        let cfg = RuntimeConfig::default();
1154        assert_eq!(cfg.packs, vec!["kg".to_string()]);
1155        if let Some(v) = prior {
1156            // SAFETY: single-threaded test cleanup; restores KHIVE_PACKS to its prior value.
1157            unsafe {
1158                std::env::set_var("KHIVE_PACKS", v);
1159            }
1160        }
1161    }
1162
1163    #[test]
1164    fn default_config_uses_minilm_when_env_unset() {
1165        let prior = std::env::var("KHIVE_EMBEDDING_MODEL").ok();
1166        // SAFETY: tests are serial by default for env mutation here; if other tests
1167        // mutate this var, mark them with the same scope.
1168        unsafe {
1169            std::env::remove_var("KHIVE_EMBEDDING_MODEL");
1170        }
1171        let cfg = RuntimeConfig::default();
1172        assert_eq!(cfg.embedding_model, Some(EmbeddingModel::AllMiniLmL6V2));
1173        if let Some(v) = prior {
1174            // SAFETY: single-threaded test cleanup; restores KHIVE_EMBEDDING_MODEL to its prior value.
1175            unsafe {
1176                std::env::set_var("KHIVE_EMBEDDING_MODEL", v);
1177            }
1178        }
1179    }
1180
1181    // ---- Actor config tests ----
1182
1183    use crate::engine_config::{ActorConfig, KhiveConfig};
1184
1185    fn khive_cfg_with_actor(id: &str) -> KhiveConfig {
1186        KhiveConfig {
1187            engines: vec![],
1188            actor: ActorConfig {
1189                id: Some(id.to_string()),
1190                display_name: None,
1191                ..Default::default()
1192            },
1193            ..KhiveConfig::default()
1194        }
1195    }
1196
1197    #[test]
1198    fn runtime_config_from_khive_config_actor_id_does_not_override_default_namespace() {
1199        // `[actor] id` must not set `default_namespace`: writes stay pinned to
1200        // `local`. A non-`'local'` actor.id is folded into the default read
1201        // visible-set, but that does not change default_namespace. This test
1202        // asserts the write-routing invariant only.
1203        let base = RuntimeConfig {
1204            git_write: Default::default(),
1205            db_path: None,
1206            default_namespace: Namespace::local(),
1207            embedding_model: None,
1208            additional_embedding_models: vec![],
1209            gate: Arc::new(AllowAllGate),
1210            packs: vec!["kg".to_string()],
1211            backend_id: BackendId::main(),
1212            brain_profile: None,
1213            visible_namespaces: vec![],
1214            allowed_outbound_namespaces: vec![],
1215            actor_id: None,
1216        };
1217        let cfg = khive_cfg_with_actor("lambda:khive");
1218        let result = runtime_config_from_khive_config(&cfg, base);
1219        assert_eq!(
1220            result.default_namespace.as_str(),
1221            "local",
1222            "actor.id must not become default_namespace (ADR-007 Rev 4 Rule 0); writes pin to local"
1223        );
1224    }
1225
1226    #[test]
1227    fn runtime_config_from_khive_config_empty_actor_id_keeps_base_namespace() {
1228        let base = RuntimeConfig {
1229            git_write: Default::default(),
1230            db_path: None,
1231            default_namespace: Namespace::parse("lambda:base").unwrap(),
1232            embedding_model: None,
1233            additional_embedding_models: vec![],
1234            gate: Arc::new(AllowAllGate),
1235            packs: vec!["kg".to_string()],
1236            backend_id: BackendId::main(),
1237            brain_profile: None,
1238            visible_namespaces: vec![],
1239            allowed_outbound_namespaces: vec![],
1240            actor_id: None,
1241        };
1242        let cfg = KhiveConfig {
1243            engines: vec![],
1244            actor: ActorConfig {
1245                id: Some(String::new()),
1246                display_name: None,
1247                ..Default::default()
1248            },
1249            ..KhiveConfig::default()
1250        };
1251        let result = runtime_config_from_khive_config(&cfg, base);
1252        assert_eq!(
1253            result.default_namespace.as_str(),
1254            "lambda:base",
1255            "empty actor.id must not override base namespace"
1256        );
1257    }
1258
1259    #[test]
1260    fn runtime_config_from_khive_config_absent_actor_id_keeps_base_namespace() {
1261        let base = RuntimeConfig {
1262            git_write: Default::default(),
1263            db_path: None,
1264            default_namespace: Namespace::parse("lambda:base").unwrap(),
1265            embedding_model: None,
1266            additional_embedding_models: vec![],
1267            gate: Arc::new(AllowAllGate),
1268            packs: vec!["kg".to_string()],
1269            backend_id: BackendId::main(),
1270            brain_profile: None,
1271            visible_namespaces: vec![],
1272            allowed_outbound_namespaces: vec![],
1273            actor_id: None,
1274        };
1275        let cfg = KhiveConfig::default(); // no actor.id
1276        let result = runtime_config_from_khive_config(&cfg, base);
1277        assert_eq!(
1278            result.default_namespace.as_str(),
1279            "lambda:base",
1280            "absent actor.id must not override base namespace"
1281        );
1282    }
1283
1284    #[test]
1285    fn runtime_config_from_khive_config_actor_id_with_engines() {
1286        let base = RuntimeConfig {
1287            git_write: Default::default(),
1288            db_path: None,
1289            default_namespace: Namespace::local(),
1290            embedding_model: None,
1291            additional_embedding_models: vec![],
1292            gate: Arc::new(AllowAllGate),
1293            packs: vec!["kg".to_string()],
1294            backend_id: BackendId::main(),
1295            brain_profile: None,
1296            visible_namespaces: vec![],
1297            allowed_outbound_namespaces: vec![],
1298            actor_id: None,
1299        };
1300        let cfg = KhiveConfig {
1301            engines: vec![crate::engine_config::EngineConfig {
1302                name: "default".to_string(),
1303                model: "all-minilm-l6-v2".to_string(),
1304                default: true,
1305                fusion_weight: None,
1306                dims: None,
1307            }],
1308            actor: ActorConfig {
1309                id: Some("lambda:test".to_string()),
1310                display_name: None,
1311                ..Default::default()
1312            },
1313            ..KhiveConfig::default()
1314        };
1315        let result = runtime_config_from_khive_config(&cfg, base);
1316        assert_eq!(
1317            result.default_namespace.as_str(),
1318            "local",
1319            "actor.id must not override default_namespace (ADR-007 Rev 4 Rule 0); \
1320             writes pin to local; engine config is still applied"
1321        );
1322        assert!(result.embedding_model.is_some());
1323    }
1324
1325    // ---- base.actor_id (env-resolved actor) preservation tests ----
1326    //
1327    // Regression coverage: a project config found without an `[actor] id` used
1328    // to silently drop `base.actor_id` (e.g. the value `RuntimeConfig::default()`
1329    // read from `KHIVE_ACTOR`) because both return arms spread an unconditional
1330    // `actor_id: None` over `..base`. The fix falls back to `base.actor_id`
1331    // when the TOML supplies no `[actor] id`, in both arms.
1332
1333    #[test]
1334    #[serial]
1335    fn runtime_config_from_khive_config_engines_present_preserves_env_actor_when_toml_has_none() {
1336        let prior = std::env::var("KHIVE_ACTOR").ok();
1337        // SAFETY: test is #[serial]; no other test in this crate reads/writes KHIVE_ACTOR.
1338        unsafe {
1339            std::env::set_var("KHIVE_ACTOR", "lambda:test-env-actor");
1340        }
1341        let base = RuntimeConfig::default();
1342        assert_eq!(base.actor_id.as_deref(), Some("lambda:test-env-actor"));
1343
1344        let cfg = KhiveConfig {
1345            engines: vec![crate::engine_config::EngineConfig {
1346                name: "default".to_string(),
1347                model: "all-minilm-l6-v2".to_string(),
1348                default: true,
1349                fusion_weight: None,
1350                dims: None,
1351            }],
1352            actor: ActorConfig::default(), // no [actor] id
1353            ..KhiveConfig::default()
1354        };
1355        let result = runtime_config_from_khive_config(&cfg, base);
1356        assert_eq!(
1357            result.actor_id.as_deref(),
1358            Some("lambda:test-env-actor"),
1359            "engines-present arm must preserve base.actor_id (env actor) when TOML has no [actor] id"
1360        );
1361
1362        // SAFETY: restores prior KHIVE_ACTOR value (test cleanup).
1363        unsafe {
1364            match prior {
1365                Some(v) => std::env::set_var("KHIVE_ACTOR", v),
1366                None => std::env::remove_var("KHIVE_ACTOR"),
1367            }
1368        }
1369    }
1370
1371    #[test]
1372    #[serial]
1373    fn runtime_config_from_khive_config_engines_empty_preserves_env_actor_when_toml_has_none() {
1374        let prior = std::env::var("KHIVE_ACTOR").ok();
1375        // SAFETY: test is #[serial]; no other test in this crate reads/writes KHIVE_ACTOR.
1376        unsafe {
1377            std::env::set_var("KHIVE_ACTOR", "lambda:test-env-actor");
1378        }
1379        let base = RuntimeConfig::default();
1380        assert_eq!(base.actor_id.as_deref(), Some("lambda:test-env-actor"));
1381
1382        let cfg = KhiveConfig {
1383            engines: vec![],
1384            actor: ActorConfig::default(), // no [actor] id
1385            ..KhiveConfig::default()
1386        };
1387        let result = runtime_config_from_khive_config(&cfg, base);
1388        assert_eq!(
1389            result.actor_id.as_deref(),
1390            Some("lambda:test-env-actor"),
1391            "engines-empty early-return arm must preserve base.actor_id (env actor) when TOML has no [actor] id"
1392        );
1393
1394        // SAFETY: restores prior KHIVE_ACTOR value (test cleanup).
1395        unsafe {
1396            match prior {
1397                Some(v) => std::env::set_var("KHIVE_ACTOR", v),
1398                None => std::env::remove_var("KHIVE_ACTOR"),
1399            }
1400        }
1401    }
1402
1403    #[test]
1404    #[serial]
1405    fn runtime_config_from_khive_config_toml_actor_wins_over_env_actor() {
1406        let prior = std::env::var("KHIVE_ACTOR").ok();
1407        // SAFETY: test is #[serial]; no other test in this crate reads/writes KHIVE_ACTOR.
1408        unsafe {
1409            std::env::set_var("KHIVE_ACTOR", "lambda:test-env-actor");
1410        }
1411        let base = RuntimeConfig::default();
1412        assert_eq!(base.actor_id.as_deref(), Some("lambda:test-env-actor"));
1413
1414        let cfg = khive_cfg_with_actor("lambda:toml-actor");
1415        let result = runtime_config_from_khive_config(&cfg, base);
1416        assert_eq!(
1417            result.actor_id.as_deref(),
1418            Some("lambda:toml-actor"),
1419            "TOML [actor] id must win over the env-resolved base.actor_id"
1420        );
1421
1422        // SAFETY: restores prior KHIVE_ACTOR value (test cleanup).
1423        unsafe {
1424            match prior {
1425                Some(v) => std::env::set_var("KHIVE_ACTOR", v),
1426                None => std::env::remove_var("KHIVE_ACTOR"),
1427            }
1428        }
1429    }
1430
1431    // ---- list_embedding_models tests ----
1432
1433    // ---- core_backend accessor tests ----
1434
1435    /// Create a migrated in-memory backend (for tests that need raw Arc<StorageBackend>).
1436    fn migrated_memory_backend() -> Arc<StorageBackend> {
1437        let backend = StorageBackend::memory().expect("memory backend");
1438        {
1439            let mut writer = backend.pool().try_writer().expect("writer");
1440            khive_db::run_migrations(writer.conn_mut()).expect("migrations");
1441        }
1442        Arc::new(backend)
1443    }
1444
1445    fn secondary_config() -> RuntimeConfig {
1446        RuntimeConfig {
1447            git_write: Default::default(),
1448            db_path: None,
1449            default_namespace: Namespace::local(),
1450            embedding_model: None,
1451            additional_embedding_models: vec![],
1452            gate: Arc::new(AllowAllGate),
1453            packs: vec!["kg".to_string()],
1454            backend_id: BackendId::new("lore"),
1455            brain_profile: None,
1456            visible_namespaces: vec![],
1457            allowed_outbound_namespaces: vec![],
1458            actor_id: None,
1459        }
1460    }
1461
1462    #[test]
1463    fn core_on_main_runtime_returns_same_backend_id() {
1464        // For a main-bound runtime, core() must return a clone with backend_id == "main".
1465        let rt = KhiveRuntime::memory().unwrap();
1466        assert_eq!(rt.backend_id().as_str(), BackendId::MAIN);
1467        let core_rt = rt.core();
1468        assert_eq!(core_rt.backend_id().as_str(), BackendId::MAIN);
1469    }
1470
1471    #[tokio::test]
1472    async fn core_on_main_runtime_round_trips_note() {
1473        // core() on a main-bound runtime (core_backend = None) returns self.clone(),
1474        // so a note written through core() is readable through the original runtime.
1475        let rt = KhiveRuntime::memory().unwrap();
1476        let tok = NamespaceToken::local();
1477
1478        let note = rt
1479            .core()
1480            .create_note(
1481                &tok,
1482                "observation",
1483                None,
1484                "adr073-main-round-trip",
1485                None,
1486                None,
1487                vec![],
1488            )
1489            .await
1490            .expect("create_note via core()");
1491
1492        let found = rt
1493            .notes(&tok)
1494            .expect("notes store")
1495            .get_note(note.id)
1496            .await
1497            .expect("get_note");
1498
1499        assert!(
1500            found.is_some(),
1501            "note written via core() must be visible through original rt"
1502        );
1503    }
1504
1505    /// Proves note→main and aux→secondary writes are each isolated.
1506    ///
1507    /// Backend A = main; backend B = secondary.
1508    /// rt_secondary is bound to B with core_backend = Some(A).
1509    ///
1510    /// Direction 1 (note → main):
1511    ///   rt_secondary.core().create_note(...) must land in A (visible from rt_main)
1512    ///   and NOT in B (not visible from rt_secondary).
1513    ///
1514    /// Direction 2 (aux → secondary):
1515    ///   A raw SQL write via rt_secondary.sql() lands in B only; A is untouched.
1516    #[tokio::test]
1517    async fn cross_backend_split_note_to_main_aux_to_secondary() {
1518        use khive_storage::{SqlStatement, SqlValue};
1519
1520        let main_arc = migrated_memory_backend();
1521        let secondary_arc = migrated_memory_backend();
1522
1523        let main_config = RuntimeConfig {
1524            git_write: Default::default(),
1525            db_path: None,
1526            default_namespace: Namespace::local(),
1527            embedding_model: None,
1528            additional_embedding_models: vec![],
1529            gate: Arc::new(AllowAllGate),
1530            packs: vec!["kg".to_string()],
1531            backend_id: BackendId::main(),
1532            brain_profile: None,
1533            visible_namespaces: vec![],
1534            allowed_outbound_namespaces: vec![],
1535            actor_id: None,
1536        };
1537
1538        let rt_main = KhiveRuntime::from_backend(main_arc.clone(), main_config);
1539        let rt_secondary = KhiveRuntime::from_backend(secondary_arc, secondary_config())
1540            .with_core_backend(main_arc.clone());
1541
1542        let tok = NamespaceToken::local();
1543
1544        // ── Direction 1: note must land in A (main), not in B (secondary) ──
1545
1546        let note = rt_secondary
1547            .core()
1548            .create_note(
1549                &tok,
1550                "observation",
1551                None,
1552                "adr073-split-test",
1553                None,
1554                None,
1555                vec![],
1556            )
1557            .await
1558            .expect("create_note via core()");
1559        let note_id = note.id;
1560
1561        // Visible from main (A).
1562        let in_main = rt_main
1563            .notes(&tok)
1564            .expect("main notes store")
1565            .get_note(note_id)
1566            .await
1567            .expect("get_note from main");
1568        assert!(
1569            in_main.is_some(),
1570            "note written via core() must appear in main backend A"
1571        );
1572
1573        // Not visible from secondary (B).
1574        let in_secondary = rt_secondary
1575            .notes(&tok)
1576            .expect("secondary notes store")
1577            .get_note(note_id)
1578            .await
1579            .expect("get_note from secondary");
1580        assert!(
1581            in_secondary.is_none(),
1582            "note written to main via core() must NOT appear in secondary backend B"
1583        );
1584
1585        // ── Direction 2: aux write via rt_secondary.sql() lands in B, not A ──
1586
1587        {
1588            let mut writer = rt_secondary.sql().writer().await.expect("secondary writer");
1589            writer
1590                .execute(SqlStatement {
1591                    sql: "CREATE TABLE IF NOT EXISTS _test_adr073_aux \
1592                          (marker TEXT PRIMARY KEY)"
1593                        .into(),
1594                    params: vec![],
1595                    label: None,
1596                })
1597                .await
1598                .expect("create aux table in B");
1599            writer
1600                .execute(SqlStatement {
1601                    sql: "INSERT INTO _test_adr073_aux VALUES (?1)".into(),
1602                    params: vec![SqlValue::Text("b-side-sentinel".into())],
1603                    label: None,
1604                })
1605                .await
1606                .expect("insert into aux table in B");
1607        }
1608
1609        // Row is present in B.
1610        let mut reader_b = rt_secondary.sql().reader().await.expect("secondary reader");
1611        let rows_b = reader_b
1612            .query_all(SqlStatement {
1613                sql: "SELECT marker FROM _test_adr073_aux".into(),
1614                params: vec![],
1615                label: None,
1616            })
1617            .await
1618            .expect("select from B");
1619        assert_eq!(rows_b.len(), 1, "aux row must exist in B");
1620        match rows_b[0].get("marker") {
1621            Some(SqlValue::Text(s)) => {
1622                assert_eq!(s, "b-side-sentinel", "sentinel value must match")
1623            }
1624            other => panic!("expected Text('b-side-sentinel'), got {other:?}"),
1625        }
1626
1627        // Row is absent from A (table does not exist there).
1628        let mut reader_a = rt_main.sql().reader().await.expect("main reader");
1629        let result_a = reader_a
1630            .query_all(SqlStatement {
1631                sql: "SELECT marker FROM _test_adr073_aux".into(),
1632                params: vec![],
1633                label: None,
1634            })
1635            .await;
1636        // A does not have this table → must error or return no rows.
1637        match result_a {
1638            Err(e) => assert!(
1639                e.to_string().contains("no such table"),
1640                "expected 'no such table' error from A, got: {e}"
1641            ),
1642            Ok(rows) => assert!(
1643                rows.is_empty(),
1644                "aux table must not have rows in A, got {} rows",
1645                rows.len()
1646            ),
1647        }
1648    }
1649
1650    #[test]
1651    fn constructors_leave_core_backend_none_by_behavior() {
1652        // core() on any standard constructor returns a clone with same backend_id —
1653        // proof that core_backend = None (returns self.clone(), not a different backend).
1654        let rt_mem = KhiveRuntime::memory().unwrap();
1655        assert_eq!(rt_mem.core().backend_id().as_str(), BackendId::MAIN);
1656
1657        let backend = migrated_memory_backend();
1658        let rt_from = KhiveRuntime::from_backend(
1659            backend,
1660            RuntimeConfig {
1661                git_write: Default::default(),
1662                db_path: None,
1663                default_namespace: Namespace::local(),
1664                embedding_model: None,
1665                additional_embedding_models: vec![],
1666                gate: Arc::new(AllowAllGate),
1667                packs: vec!["kg".to_string()],
1668                backend_id: BackendId::new("lore"),
1669                brain_profile: None,
1670                visible_namespaces: vec![],
1671                allowed_outbound_namespaces: vec![],
1672                actor_id: None,
1673            },
1674        );
1675        // from_backend with backend_id="lore" and no core_backend: core() returns
1676        // self.clone() which has backend_id="lore" (not "main").
1677        assert_eq!(rt_from.core().backend_id().as_str(), "lore");
1678    }
1679
1680    #[test]
1681    fn with_core_backend_sets_core_then_core_returns_main_id() {
1682        // After wiring, core() must return a runtime with backend_id == "main".
1683        let main_arc = migrated_memory_backend();
1684        let secondary_arc = migrated_memory_backend();
1685
1686        let rt_secondary = KhiveRuntime::from_backend(secondary_arc, secondary_config())
1687            .with_core_backend(main_arc);
1688
1689        assert_eq!(rt_secondary.backend_id().as_str(), "lore");
1690        assert_eq!(
1691            rt_secondary.core().backend_id().as_str(),
1692            BackendId::MAIN,
1693            "core() on a secondary runtime must return a main-bound handle"
1694        );
1695    }
1696
1697    #[tokio::test]
1698    async fn list_embedding_models_returns_empty_when_table_absent() {
1699        // A brand-new in-memory runtime has migrations applied, so _embedding_models
1700        // IS created. But with no rows inserted, the result must be empty.
1701        let rt = KhiveRuntime::memory().expect("memory runtime");
1702        let records = rt
1703            .list_embedding_models(None)
1704            .await
1705            .expect("list ok on empty table");
1706        assert!(records.is_empty());
1707    }
1708
1709    #[tokio::test]
1710    async fn list_embedding_models_returns_row_after_insert() {
1711        use khive_storage::{SqlStatement, SqlValue};
1712
1713        let rt = KhiveRuntime::memory().expect("memory runtime");
1714        let sql = rt.sql();
1715
1716        let now = 1_000_000i64;
1717        let id = uuid::Uuid::new_v4();
1718        let canonical_key = b"test_engine:test-model-v1:v1:384".to_vec();
1719
1720        let mut writer = sql.writer().await.expect("writer");
1721        writer
1722            .execute(SqlStatement {
1723                sql: "INSERT INTO _embedding_models \
1724                      (id, engine_name, model_id, key_version, dim, output_dim, status, \
1725                       activated_at, superseded_at, superseded_by, canonical_key, created_at) \
1726                      VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, NULL, NULL, ?8, ?9)"
1727                    .into(),
1728                params: vec![
1729                    SqlValue::Blob(id.as_bytes().to_vec()),
1730                    SqlValue::Text("test_engine".into()),
1731                    SqlValue::Text("test-model-v1".into()),
1732                    SqlValue::Text("v1".into()),
1733                    SqlValue::Integer(384),
1734                    SqlValue::Text("active".into()),
1735                    SqlValue::Integer(now),
1736                    SqlValue::Blob(canonical_key),
1737                    SqlValue::Integer(now),
1738                ],
1739                label: None,
1740            })
1741            .await
1742            .expect("insert row");
1743        drop(writer);
1744
1745        let records = rt.list_embedding_models(None).await.expect("list ok");
1746        assert_eq!(records.len(), 1);
1747        assert_eq!(records[0].engine_name, "test_engine");
1748        assert_eq!(records[0].model_id, "test-model-v1");
1749        assert_eq!(records[0].key_version, "v1");
1750        assert_eq!(records[0].dimensions, 384);
1751        assert_eq!(records[0].status, "active");
1752
1753        // engine filter — match
1754        let filtered = rt
1755            .list_embedding_models(Some("test_engine"))
1756            .await
1757            .expect("filter ok");
1758        assert_eq!(filtered.len(), 1);
1759
1760        // engine filter — no match
1761        let no_match = rt
1762            .list_embedding_models(Some("other_engine"))
1763            .await
1764            .expect("no-match ok");
1765        assert!(no_match.is_empty());
1766    }
1767}