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