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