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