Skip to main content

khive_runtime/
config.rs

1//! RuntimeConfig, BackendId, NamespaceToken, and embedding model helpers.
2
3use std::sync::Arc;
4
5use khive_db::StorageBackend;
6use khive_gate::{ActorRef, AllowAllGate, GateRef};
7use khive_types::Namespace;
8use lattice_embed::EmbeddingModel;
9
10use crate::error::RuntimeResult;
11
12// ---- BackendId ----
13
14/// Identifies a named backend in a multi-backend deployment.
15///
16/// The `main` backend is the default single-backend name. Multi-backend deployments
17/// assign each `[[backends]]` entry a distinct `BackendId`. The
18/// `SubstrateCoordinator` in `kkernel`
19/// uses `BackendId` for node-to-backend resolution and cross-backend edge routing.
20///
21/// A single-backend `KhiveRuntime` always has `BackendId("main")` by default.
22/// The boot path in `kkernel` or `khive-mcp` sets the id via `RuntimeConfig::backend_id`
23/// when constructing per-pack runtimes.
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25pub struct BackendId(pub String);
26
27impl BackendId {
28    /// The default single-backend name.
29    pub const MAIN: &'static str = "main";
30
31    /// Construct from a string name.
32    pub fn new(name: impl Into<String>) -> Self {
33        Self(name.into())
34    }
35
36    /// The default `main` backend id.
37    pub fn main() -> Self {
38        Self(Self::MAIN.to_string())
39    }
40
41    /// Return the backend name as a `&str`.
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl std::fmt::Display for BackendId {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.write_str(&self.0)
50    }
51}
52
53// ---- Sealed token ----
54
55mod private {
56    #[derive(Clone, Debug)]
57    pub(crate) struct Sealed;
58}
59
60/// Authorization proof that a caller is permitted to access a specific namespace.
61///
62/// Created by [`crate::VerbRegistry::dispatch`] after the gate approves the request.
63/// The sealed inner field prevents external code from constructing a token
64/// without going through the authorization path.
65///
66/// The `namespace` field is the **write namespace**: all records created via
67/// this token land in that namespace. `visible` is the **read visibility set**:
68/// list/search/get operations will return records from any namespace in this
69/// set. The write namespace is always a member of the visible set.
70///
71/// Single-namespace behaviour (backward-compatible default): `visible` contains
72/// exactly `[namespace]` — identical to the old strict-equality checks.
73#[derive(Clone, Debug)]
74pub struct NamespaceToken {
75    namespace: Namespace,
76    visible: Vec<Namespace>,
77    actor: ActorRef,
78    _sealed: private::Sealed,
79}
80
81impl NamespaceToken {
82    /// Mint an authorized token with an extended visibility set.
83    ///
84    /// `extra_visible` lists namespaces beyond the primary that the token may
85    /// read. The primary namespace is always included in the visible set
86    /// regardless of what `extra_visible` contains. Duplicates are removed.
87    pub(crate) fn mint_with_visibility(
88        namespace: Namespace,
89        extra_visible: Vec<Namespace>,
90        actor: ActorRef,
91    ) -> Self {
92        let mut visible = vec![namespace.clone()];
93        for ns in extra_visible {
94            if !visible.contains(&ns) {
95                visible.push(ns);
96            }
97        }
98        debug_assert!(!visible.is_empty(), "visible set must be non-empty");
99        Self {
100            namespace,
101            visible,
102            actor,
103            _sealed: private::Sealed,
104        }
105    }
106
107    /// Mint an authorized token. Only callable from within `khive-runtime`.
108    ///
109    /// The visible set defaults to `[namespace]` — backward-compatible with
110    /// single-namespace enforcement.
111    pub(crate) fn mint_authorized(namespace: Namespace, actor: ActorRef) -> Self {
112        Self::mint_with_visibility(namespace, vec![], actor)
113    }
114
115    /// Convenience constructor for the local namespace with an anonymous actor.
116    ///
117    /// Only callable from within `khive-runtime`. External callers must use
118    /// [`KhiveRuntime::authorize`] to mint tokens.
119    // Used only in #[cfg(test)] blocks within this crate's src/ files.
120    #[allow(dead_code)]
121    pub(crate) fn local() -> Self {
122        Self::mint_authorized(Namespace::local(), ActorRef::anonymous())
123    }
124
125    /// Convenience constructor for a specific namespace with an anonymous actor.
126    ///
127    /// Only callable from within `khive-runtime`. External callers must use
128    /// [`KhiveRuntime::authorize`] to mint tokens.
129    // Used only in #[cfg(test)] blocks within this crate's src/ files.
130    #[allow(dead_code)]
131    pub(crate) fn for_namespace(ns: Namespace) -> Self {
132        Self::mint_authorized(ns, ActorRef::anonymous())
133    }
134
135    /// Return the write namespace this token authorises.
136    ///
137    /// All records created via this token land in this namespace.
138    pub fn namespace(&self) -> &Namespace {
139        &self.namespace
140    }
141
142    /// Return the read-visibility set.
143    ///
144    /// List, search, and get operations must accept records whose namespace is
145    /// a member of this set. The write namespace is always included.
146    pub fn visible_namespaces(&self) -> &[Namespace] {
147        &self.visible
148    }
149
150    /// Return a deduplicated list of visible namespace strings (borrowed).
151    ///
152    /// Convenience for passing directly to storage layer filters.
153    pub fn visible_namespace_strs(&self) -> Vec<&str> {
154        self.visible.iter().map(|ns| ns.as_str()).collect()
155    }
156
157    /// Return the actor reference embedded in this token.
158    pub fn actor(&self) -> &ActorRef {
159        &self.actor
160    }
161
162    /// Return a new token with the same actor but a different namespace.
163    ///
164    /// The visible set is replaced with `[ns]`: this is a full read+write token
165    /// for `ns`, not a type-enforced write-only or append-only capability. It is
166    /// a capability-transfer primitive, not a policy gate: callers must enforce
167    /// any ACL check before calling this and use the minted token only within
168    /// the intended narrow scope (e.g. a single `create_note` call). A future
169    /// security model should replace this pattern with a type-enforced
170    /// append-only capability that goes through the Gate.
171    pub fn with_namespace(&self, ns: Namespace) -> Self {
172        Self::mint_authorized(ns, self.actor.clone())
173    }
174}
175
176// ---- RuntimeConfig ----
177
178/// Runtime configuration.
179///
180/// The `db_path` and `embedding_model` fields are deprecated in favour of
181/// constructing the backend externally and calling [`crate::KhiveRuntime::from_backend`].
182/// They remain for backward compatibility with tests and single-binary deployments.
183#[derive(Clone, Debug)]
184pub struct RuntimeConfig {
185    /// Path to the SQLite database file. `None` = in-memory (tests).
186    ///
187    /// Deprecated: use [`crate::KhiveRuntime::from_backend`] instead. The boot path
188    /// constructs backends from `khive.toml` (`AppConfig`) and passes them to
189    /// `from_backend`. Direct `db_path` usage persists only in tests.
190    pub db_path: Option<std::path::PathBuf>,
191    /// Namespace used when no explicit namespace is provided.
192    pub default_namespace: Namespace,
193    /// Local embedding model. `None` alone does not disable embedding: setting
194    /// only this field to `None` while `additional_embedding_models` is
195    /// non-empty still registers those models. Both `embedding_model` and
196    /// `additional_embedding_models` must be empty to disable built-in
197    /// embedding model registration, at which point `hybrid_search` falls back
198    /// to text-only. Use [`RuntimeConfig::no_embeddings`] to clear both fields
199    /// together — it is the canonical constructor for this. Custom embedder
200    /// providers registered later by packs are not affected by this field.
201    ///
202    /// Deprecated: embedding engines move to a per-pack `EmbedderRegistry`.
203    /// This field persists for backward compatibility until the embedder registry
204    /// is fully plumbed.
205    pub embedding_model: Option<EmbeddingModel>,
206    /// Additional embedding models to make available by request name.
207    ///
208    /// `embedding_model` remains the default used by existing `embed()` and
209    /// `embed_batch()` callers. This list adds non-default models that can be
210    /// selected with `embedder(name)`, `embed_with_model(...)`, memory
211    /// `remember.embedding_model`, and memory `recall.embedding_model`.
212    pub additional_embedding_models: Vec<EmbeddingModel>,
213    /// Authorization gate consulted before each verb dispatch.
214    /// Default: `AllowAllGate` (permissive). For production policy enforcement,
215    /// plug in a Rego- or capability-witness-backed impl.
216    pub gate: GateRef,
217    /// Names of packs the transport layer should register into the VerbRegistry.
218    /// The transport layer (e.g. `khive-mcp`) reads this list and instantiates
219    /// the matching concrete pack types. Unknown names are reported as errors
220    /// by the transport, not silently ignored.
221    /// Default: `["kg"]`.
222    pub packs: Vec<String>,
223    /// Identifies this runtime's backend in a multi-backend deployment.
224    ///
225    /// Set by the boot path when constructing per-pack runtimes from `khive.toml`.
226    /// Single-backend deployments use the default `BackendId::MAIN`.
227    pub backend_id: BackendId,
228    /// Brain profile to use for `memory.feedback` / `knowledge.feedback` and
229    /// recall-time score boosting (ADR-035 §Brain profile configuration).
230    ///
231    /// Resolution order (highest to lowest, ADR-035): CLI flag, then
232    /// `runtime.brain_profile` in project/global `khive.toml`, then the
233    /// `KHIVE_BRAIN_PROFILE` env var as fallback default. Callers must keep
234    /// env OUT of the base config they pass in (see `khive-mcp` serve.rs).
235    /// 1. `--brain-profile` CLI flag (explicit only)
236    /// 2. Namespace-bound profile resolved via `brain.resolve` at feedback time
237    /// 3. Pack-local global tuning prior (default fallback)
238    pub brain_profile: Option<String>,
239    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
240    ///
241    /// OSS dispatch widens the DEFAULT multi-record read scope to
242    /// `['local'] ∪ visible_namespaces`. Writes remain pinned to `'local'`.
243    /// An explicit `namespace=` request param is a precise single-namespace
244    /// escape and is not widened. Populated from `actor.visible_namespaces`
245    /// in `khive.toml`.
246    pub visible_namespaces: Vec<Namespace>,
247    /// Namespaces this actor's comm.send/reply may deliver messages INTO
248    /// (outbound, sender-side). Populated from `actor.allowed_outbound_namespaces`
249    /// in `khive.toml`. Empty by default — cross-namespace delivery denied
250    /// unless explicitly declared. The comm handler uses an ordinary
251    /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
252    /// the token itself is NOT type-enforced write-only. The recipient-side
253    /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
254    /// a future cloud-path authorization ADR (not yet written).
255    pub allowed_outbound_namespaces: Vec<Namespace>,
256    /// Configured actor identity label (ADR-057). Populated from `[actor] id` in
257    /// `khive.toml`. When `Some`, `authorize()` mints tokens carrying this actor
258    /// label so that `comm.inbox` filters by `to_actor` instead of falling back to
259    /// the party-line "local" behavior. When `None` (default), tokens carry
260    /// `ActorRef::anonymous()` and inbox is scoped to party-line messages —
261    /// those addressed to `"local"` or carrying no `to_actor` stamp.
262    pub actor_id: Option<String>,
263    /// Resolved `[git_write]` policy allowlist (ADR-108 Amendment), populated
264    /// from `khive.toml`'s `[[git_write.allowed]]` entries by
265    /// [`runtime_config_from_khive_config`]. Threaded through so
266    /// a git-integration pack's write-verb handlers read an already-resolved policy
267    /// instead of re-running config discovery (which would ignore an
268    /// explicit `--config` path not also exported as `KHIVE_CONFIG`).
269    pub git_write: crate::engine_config::GitWriteSectionConfig,
270}
271
272/// Parse a comma- or whitespace-separated pack list from a single string.
273///
274/// Empty entries are dropped, surrounding whitespace is trimmed.
275pub fn parse_pack_list(s: &str) -> Vec<String> {
276    s.split(|c: char| c == ',' || c.is_whitespace())
277        .map(str::trim)
278        .filter(|s| !s.is_empty())
279        .map(str::to_owned)
280        .collect()
281}
282
283impl Default for RuntimeConfig {
284    fn default() -> Self {
285        let db_path = std::env::var("HOME")
286            .ok()
287            .map(|h| std::path::PathBuf::from(h).join(".khive/khive.db"));
288        let embedding_model = std::env::var("KHIVE_EMBEDDING_MODEL")
289            .ok()
290            .and_then(|s| s.parse().ok())
291            .or(Some(EmbeddingModel::AllMiniLmL6V2));
292        // Ships single-engine. A second engine is embedded on every write and
293        // searched on every read, which is cost a deployment should opt into
294        // rather than inherit: set KHIVE_ADDITIONAL_EMBEDDING_MODELS to add one.
295        let additional_embedding_models = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
296            .ok()
297            .map(|s| parse_embedding_model_list(&s))
298            .unwrap_or_default();
299        let packs = std::env::var("KHIVE_PACKS")
300            .ok()
301            .map(|s| parse_pack_list(&s))
302            .filter(|v| !v.is_empty())
303            .unwrap_or_else(|| vec!["kg"].into_iter().map(String::from).collect());
304        let brain_profile = std::env::var("KHIVE_BRAIN_PROFILE")
305            .ok()
306            .filter(|s| !s.trim().is_empty());
307        let actor_id = std::env::var("KHIVE_ACTOR")
308            .ok()
309            .filter(|s| !s.trim().is_empty());
310        Self {
311            db_path,
312            default_namespace: Namespace::local(),
313            embedding_model,
314            additional_embedding_models,
315            gate: Arc::new(AllowAllGate),
316            packs,
317            backend_id: BackendId::main(),
318            brain_profile,
319            visible_namespaces: vec![],
320            allowed_outbound_namespaces: vec![],
321            actor_id,
322            git_write: crate::engine_config::GitWriteSectionConfig::default(),
323        }
324    }
325}
326
327impl RuntimeConfig {
328    /// Build a `RuntimeConfig` with embedding disabled entirely.
329    ///
330    /// `embedding_model` and `additional_embedding_models` are computed
331    /// independently inside [`Default::default`], so `RuntimeConfig {
332    /// embedding_model: None, ..RuntimeConfig::default() }` does NOT produce a
333    /// model-less runtime: `additional_embedding_models` still carries its
334    /// env-driven fallback seed, and the note-write path fans out embedding to
335    /// every registered model regardless of `embedding_model`: so the first
336    /// `memory.remember` on a machine without local model files hard-fails
337    /// instead of degrading to FTS-only.
338    ///
339    /// This constructor clears both fields together and ignores
340    /// `KHIVE_ADDITIONAL_EMBEDDING_MODELS` unconditionally: the caller wants
341    /// zero embedders, not "zero unless the environment disagrees". Use it on
342    /// model-less machines (CI runners, fresh installs without local model
343    /// files) instead of the two-field struct-update form.
344    pub fn no_embeddings() -> Self {
345        Self {
346            embedding_model: None,
347            additional_embedding_models: Vec::new(),
348            ..Self::default()
349        }
350    }
351}
352
353/// Resolve the `--db`/`KHIVE_DB` value into the anchor path used for tier-3
354/// project-local `.khive/config.toml` discovery, mirroring the precedence
355/// `kkernel mcp` and `kkernel exec` use to open the database itself:
356/// `:memory:` has no file to anchor on (`None`); an explicit path anchors on
357/// that path; an unset value falls back to `$HOME/.khive/khive.db`, or
358/// `./.khive/khive.db` when `HOME` is unset.
359///
360/// Always resolves to a concrete anchor (unlike a 2-arm "override the
361/// default?" resolver): when `HOME` is unset this falls back to
362/// `./.khive/khive.db` rather than `None`, deliberately diverging from
363/// `RuntimeConfig::default()`: a caller anchoring config discovery needs a
364/// concrete directory to search even without `HOME`.
365pub fn resolve_db_anchor(db: Option<&str>) -> Option<std::path::PathBuf> {
366    match db {
367        Some(":memory:") => None,
368        Some(path) => Some(std::path::PathBuf::from(path)),
369        None => {
370            let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
371            Some(std::path::PathBuf::from(format!("{home}/.khive/khive.db")))
372        }
373    }
374}
375
376/// Assert that a resolved `db_path`: which `compute_config_id` folds into a
377/// process's `config_id`: agrees with what [`resolve_db_anchor`] derives from
378/// the same raw `--db`/`KHIVE_DB` input.
379///
380/// This compatibility entry point preserves the raw-string API. Construction
381/// paths that already captured the anchor should call
382/// [`assert_captured_db_anchor_consistent`] so they do not re-read mutable
383/// process environment.
384pub fn assert_db_anchor_consistent(
385    resolved_db_path: Option<&std::path::Path>,
386    args_db: Option<&str>,
387) -> anyhow::Result<()> {
388    let db_anchor = resolve_db_anchor(args_db);
389    assert_captured_db_anchor_consistent(resolved_db_path, db_anchor.as_deref())
390}
391
392/// Assert that a resolved `db_path`: which `compute_config_id` folds into a
393/// process's `config_id`: agrees with the database anchor captured from the
394/// same `--db`/`KHIVE_DB` input at the construction boundary.
395///
396/// Guards against a construction path recomputing `db_path` independently of
397/// `resolve_db_anchor`: left unchecked, that would silently desync `config_id`
398/// from a daemon or peer sharing the same database instead of failing loud.
399/// The caller passes the captured anchor so validation never re-reads mutable
400/// process environment. Inert (`Ok(())`) when the anchor itself is `None` (the
401/// `:memory:` sentinel) since there is nothing to compare against.
402pub fn assert_captured_db_anchor_consistent(
403    resolved_db_path: Option<&std::path::Path>,
404    db_anchor: Option<&std::path::Path>,
405) -> anyhow::Result<()> {
406    let Some(anchor) = db_anchor else {
407        return Ok(());
408    };
409    if resolved_db_path != Some(anchor) {
410        anyhow::bail!(
411            "db-path resolution drift at server construction: resolved db_path {:?} \
412             does not match the canonical anchor {:?} computed by resolve_db_anchor \
413             from the same --db input; this construction path likely recomputed the \
414             db path independently instead of routing through the shared resolver, \
415             which would desynchronize config_id from other processes sharing the \
416             same database",
417            resolved_db_path,
418            anchor
419        );
420    }
421    Ok(())
422}
423
424/// Resolve the per-connection attribution actor from the project/cwd-anchored
425/// config tier, independently of the database-anchored config load that
426/// governs `config_id`.
427///
428/// The database-anchored config load keeps `config_id` coherent between a
429/// short-lived client and a long-running daemon sharing one database, but
430/// when many per-project connections share one database under a single
431/// `HOME` (the daemon-multiplexed fleet case), that shared config carries no
432/// `[actor]` block, so every connection's write-stamp attribution collapses
433/// to the default identity.
434///
435/// This performs a separate, cwd-anchored lookup (`db_path: None`) and reads
436/// only `[actor].id`: it must not perturb `config_id` or `default_namespace`,
437/// which remain governed exclusively by the database-anchored load.
438///
439/// `config_path` is the same explicit `--config`/`KHIVE_CONFIG` override the
440/// caller's database-anchored load receives, so an explicit override wins
441/// here too.
442///
443/// Returns `Ok(None)` when no project-anchored config exists, or it exists
444/// but carries no non-empty `[actor].id`: callers fall through to their own
445/// env/anonymous tiers in that case.
446pub fn resolve_project_actor_id(
447    config_path: Option<&std::path::Path>,
448) -> Result<Option<String>, crate::engine_config::ConfigError> {
449    let khive_cfg = crate::engine_config::KhiveConfig::load_with_home_fallback(config_path, None)?;
450    Ok(khive_cfg
451        .and_then(|cfg| cfg.actor.id)
452        .filter(|s| !s.trim().is_empty()))
453}
454
455// ---- Embedding model helpers ----
456
457/// Sanitize an embedding model name into a valid SQL table suffix.
458/// e.g. `bge-small-en-v1.5` -> `bge_small_en_v1_5`
459pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
460    sanitize_key(&model.to_string())
461}
462
463pub(crate) fn sanitize_key(s: &str) -> String {
464    s.chars()
465        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
466        .collect()
467}
468
469pub(crate) fn build_embedder_registry(
470    config: &RuntimeConfig,
471) -> (crate::embedder_registry::EmbedderRegistry, Arc<str>) {
472    use crate::embedder_registry::{EmbedderRegistry, LatticeEmbedderProvider};
473    let mut registry = EmbedderRegistry::new();
474    for model in configured_embedding_models(config) {
475        registry.register(LatticeEmbedderProvider::new(model));
476    }
477    let default_embedder_name = config
478        .embedding_model
479        .map(|model| Arc::<str>::from(model.to_string()))
480        .unwrap_or_else(|| Arc::<str>::from(""));
481    (registry, default_embedder_name)
482}
483
484fn configured_embedding_models(config: &RuntimeConfig) -> Vec<EmbeddingModel> {
485    let mut models: Vec<EmbeddingModel> = Vec::new();
486    if let Some(model) = config.embedding_model {
487        models.push(model);
488    }
489    for model in config.additional_embedding_models.iter().copied() {
490        if !models.contains(&model) {
491            models.push(model);
492        }
493    }
494    models
495}
496
497pub(crate) fn register_configured_embedding_models(
498    backend: &StorageBackend,
499    config: &RuntimeConfig,
500) -> RuntimeResult<()> {
501    for model in configured_embedding_models(config) {
502        backend.register_embedding_model(
503            &model.to_string(),
504            model.model_id(),
505            model.key_version(),
506            model.dimensions() as u32,
507        )?;
508    }
509    Ok(())
510}
511
512/// Build a `RuntimeConfig` from a parsed `KhiveConfig`.
513///
514/// For each `[[engines]]` entry:
515/// - The engine flagged `default = true` becomes `RuntimeConfig::embedding_model`.
516/// - All other engines become `RuntimeConfig::additional_embedding_models`.
517///
518/// Model name validity is checked here: any engine whose `model` field cannot
519/// be parsed via `parse_embedding_model_alias` is skipped with a warning.
520///
521/// If `khive_cfg.engines` is empty, the returned `RuntimeConfig` uses the
522/// env-var-derived defaults from `RuntimeConfig::default()`.
523///
524/// When both a config file and `KHIVE_EMBEDDING_MODEL` env var are present,
525/// the caller is responsible for emitting a warning that env vars are overridden.
526/// This function purely converts `KhiveConfig` to `RuntimeConfig` fields.
527pub fn runtime_config_from_khive_config(
528    khive_cfg: &crate::engine_config::KhiveConfig,
529    base: RuntimeConfig,
530) -> RuntimeConfig {
531    // `[actor] id` never becomes the storage namespace (writes always pin to
532    // `local`); it only widens the read visible-set below.
533    let default_namespace = base.default_namespace.clone();
534
535    // base.brain_profile must carry only the explicit CLI tier, never an env
536    // value: env sits below toml in precedence and is applied later by the MCP resolver.
537    let brain_profile = base.brain_profile.clone().or_else(|| {
538        khive_cfg
539            .runtime
540            .brain_profile
541            .clone()
542            .filter(|s| !s.trim().is_empty())
543    });
544
545    let visible_namespaces: Vec<Namespace> = khive_cfg
546        .actor
547        .visible_namespaces
548        .as_deref()
549        .unwrap_or_default()
550        .iter()
551        .filter_map(|s| match Namespace::parse(s) {
552            Ok(ns) => Some(ns),
553            Err(e) => {
554                tracing::warn!(ns = %s, error = %e, "actor.visible_namespaces: invalid namespace; skipped");
555                None
556            }
557        })
558        .collect();
559
560    // Fold actor.id's namespace into visible_namespaces so default reads widen
561    // to {local} ∪ {actor namespace}; skipped when it parses to `local` (would
562    // duplicate the primary namespace already minted) or is already present.
563    let visible_namespaces = if let Some(id) = khive_cfg.actor.id.as_deref() {
564        match Namespace::parse(id) {
565            Ok(actor_ns) if actor_ns != Namespace::local() => {
566                let mut v = visible_namespaces;
567                if !v.contains(&actor_ns) {
568                    v.push(actor_ns);
569                }
570                v
571            }
572            _ => visible_namespaces,
573        }
574    } else {
575        visible_namespaces
576    };
577
578    // KhiveConfig::validate() guarantees these are valid Namespace strings, so
579    // parse failures here are unreachable for validated configs; filter_map+warn
580    // guards against a validation bug panicking instead.
581    let allowed_outbound_namespaces: Vec<Namespace> = khive_cfg
582        .actor
583        .allowed_outbound_namespaces
584        .iter()
585        .filter_map(|s| match Namespace::parse(s) {
586            Ok(ns) => Some(ns),
587            Err(e) => {
588                tracing::warn!(ns = %s, error = %e, "actor.allowed_outbound_namespaces: invalid namespace; skipped");
589                None
590            }
591        })
592        .collect();
593
594    // Precedence: TOML `[actor] id` > `base.actor_id` (env/CLI-resolved) >
595    // anonymous. Falls back to `base.actor_id` rather than `None` when
596    // `[actor] id` is absent: otherwise an env-resolved actor like
597    // `KHIVE_ACTOR` is silently dropped whenever a project config exists
598    // without an `[actor]` block.
599    let actor_id = khive_cfg
600        .actor
601        .id
602        .clone()
603        .filter(|s| !s.trim().is_empty())
604        .or_else(|| base.actor_id.clone());
605
606    let git_write = khive_cfg.git_write.clone();
607
608    if khive_cfg.engines.is_empty() {
609        return RuntimeConfig {
610            default_namespace,
611            brain_profile,
612            visible_namespaces,
613            allowed_outbound_namespaces,
614            actor_id,
615            git_write,
616            ..base
617        };
618    }
619
620    let mut embedding_model: Option<EmbeddingModel> = None;
621    let mut additional: Vec<EmbeddingModel> = Vec::new();
622
623    for engine in &khive_cfg.engines {
624        match parse_embedding_model_alias(&engine.model) {
625            Some(model) => {
626                if engine.default {
627                    embedding_model = Some(model);
628                } else {
629                    additional.push(model);
630                }
631            }
632            None => {
633                tracing::warn!(
634                    engine = %engine.name,
635                    model = %engine.model,
636                    "engine config: unknown model name; engine will be skipped"
637                );
638            }
639        }
640    }
641
642    RuntimeConfig {
643        embedding_model,
644        additional_embedding_models: additional,
645        default_namespace,
646        brain_profile,
647        visible_namespaces,
648        allowed_outbound_namespaces,
649        actor_id,
650        git_write,
651        ..base
652    }
653}
654
655/// Parse a comma- or whitespace-separated list of embedding model names.
656fn parse_embedding_model_list(s: &str) -> Vec<EmbeddingModel> {
657    parse_pack_list(s)
658        .into_iter()
659        .filter_map(|raw| {
660            let parsed = parse_embedding_model_alias(&raw);
661            if parsed.is_none() && !raw.trim().is_empty() {
662                tracing::warn!(
663                    model = %raw,
664                    "KHIVE_ADDITIONAL_EMBEDDING_MODELS contains unknown model name; ignored. \
665                     Valid forms: short alias like 'paraphrase' or a fully-qualified key \
666                     from lattice_embed::EmbeddingModel::from_str."
667                );
668            }
669            parsed
670        })
671        .collect()
672}
673
674pub(crate) fn parse_embedding_model_alias(name: &str) -> Option<EmbeddingModel> {
675    let normalized = name.trim().to_ascii_lowercase().replace('_', "-");
676    match normalized.as_str() {
677        "paraphrase" => Some(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2),
678        _ => normalized.parse().ok(),
679    }
680}
681
682#[cfg(test)]
683mod resolve_db_anchor_tests {
684    use super::resolve_db_anchor;
685
686    #[test]
687    fn memory_sentinel_maps_to_none() {
688        assert_eq!(resolve_db_anchor(Some(":memory:")), None);
689    }
690
691    #[test]
692    fn explicit_path_maps_to_some() {
693        assert_eq!(
694            resolve_db_anchor(Some("/tmp/khive-anchor-test.db")),
695            Some(std::path::PathBuf::from("/tmp/khive-anchor-test.db"))
696        );
697    }
698
699    #[test]
700    fn absent_maps_to_home_default() {
701        let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
702        let expected = std::path::PathBuf::from(format!("{home}/.khive/khive.db"));
703        assert_eq!(resolve_db_anchor(None), Some(expected));
704    }
705}
706
707#[cfg(test)]
708mod assert_db_anchor_consistent_tests {
709    use super::{assert_captured_db_anchor_consistent, resolve_db_anchor};
710    use crate::assert_db_anchor_consistent;
711
712    #[test]
713    fn diverging_db_path_is_rejected_naming_both_paths() {
714        let args_db = "/tmp/khive-anchor-guard-real.db";
715        let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
716        let wrong = std::path::PathBuf::from("/tmp/khive-anchor-guard-wrong.db");
717
718        let err =
719            assert_captured_db_anchor_consistent(Some(wrong.as_path()), Some(anchor.as_path()))
720                .expect_err("a resolved db_path diverging from the anchor must be rejected");
721
722        let msg = err.to_string();
723        assert!(
724            msg.contains(&wrong.display().to_string()),
725            "error must name the resolved (wrong) path: {msg}"
726        );
727        assert!(
728            msg.contains(&anchor.display().to_string()),
729            "error must name the canonical anchor path: {msg}"
730        );
731    }
732
733    #[test]
734    fn matching_explicit_db_path_passes() {
735        let args_db = "/tmp/khive-anchor-guard-consistent.db";
736        let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
737        assert!(assert_captured_db_anchor_consistent(
738            Some(anchor.as_path()),
739            Some(anchor.as_path())
740        )
741        .is_ok());
742    }
743
744    #[test]
745    fn memory_sentinel_anchor_is_inert() {
746        // `resolve_db_anchor(":memory:")` yields `None` — there is no canonical
747        // path to assert against, so the guard passes regardless of what
748        // `resolved_db_path` happens to carry.
749        let bogus = std::path::PathBuf::from("/tmp/should-not-matter.db");
750        assert!(assert_captured_db_anchor_consistent(Some(bogus.as_path()), None).is_ok());
751        assert!(assert_captured_db_anchor_consistent(None, None).is_ok());
752    }
753
754    #[test]
755    fn normal_boot_with_db_unset_passes_silently() {
756        // Mirrors a normal boot with `--db` unset: `resolve_db_anchor(None)`
757        // always resolves to `Some(..)` (HOME-set or -unset both produce a
758        // concrete anchor), so a runtime whose resolved `db_path` matches
759        // passes silently.
760        let anchor = resolve_db_anchor(None);
761        assert!(assert_captured_db_anchor_consistent(anchor.as_deref(), anchor.as_deref()).is_ok());
762    }
763
764    #[test]
765    fn public_compatibility_wrapper_accepts_path_and_memory_sentinel() {
766        let args_db = "/tmp/khive-anchor-guard-public-api.db";
767        let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
768        assert!(assert_db_anchor_consistent(Some(anchor.as_path()), Some(args_db)).is_ok());
769
770        let unrelated = std::path::Path::new("/tmp/khive-anchor-guard-unrelated.db");
771        assert!(assert_db_anchor_consistent(Some(unrelated), Some(":memory:")).is_ok());
772    }
773}
774
775#[cfg(test)]
776mod resolve_project_actor_id_tests {
777    use super::resolve_project_actor_id;
778
779    fn write_toml(dir: &tempfile::TempDir, body: &str) -> std::path::PathBuf {
780        let path = dir.path().join("config.toml");
781        std::fs::write(&path, body).expect("write config.toml");
782        path
783    }
784
785    #[test]
786    fn extracts_non_empty_actor_id_from_explicit_path() {
787        let dir = tempfile::tempdir().expect("tempdir");
788        let path = write_toml(&dir, "[actor]\nid = \"lambda:explicit-actor\"\n");
789
790        assert_eq!(
791            resolve_project_actor_id(Some(&path)).expect("no error"),
792            Some("lambda:explicit-actor".to_string())
793        );
794    }
795
796    #[test]
797    fn returns_none_for_missing_explicit_path() {
798        let missing = std::path::PathBuf::from("/nonexistent/khive-project-actor-test/config.toml");
799        assert_eq!(
800            resolve_project_actor_id(Some(&missing)).expect("no error"),
801            None,
802            "a nonexistent explicit path must resolve to None, not an error"
803        );
804    }
805
806    #[test]
807    fn propagates_load_error_for_invalid_actor_id() {
808        // `KhiveConfig::load`'s `validate()` rejects an empty `[actor] id` before
809        // the emptiness filter in `resolve_project_actor_id` ever sees it; this
810        // asserts the error surfaces rather than being swallowed into `Ok(None)`.
811        let dir = tempfile::tempdir().expect("tempdir");
812        let path = write_toml(&dir, "[actor]\nid = \"\"\n");
813
814        let err = resolve_project_actor_id(Some(&path)).expect_err("invalid actor.id must error");
815        assert!(
816            matches!(
817                err,
818                crate::engine_config::ConfigError::InvalidActorId { .. }
819            ),
820            "expected InvalidActorId, got {err:?}"
821        );
822    }
823
824    #[test]
825    fn returns_none_when_config_has_no_actor_section() {
826        let dir = tempfile::tempdir().expect("tempdir");
827        let path = write_toml(
828            &dir,
829            "[[engines]]\nname = \"primary\"\nmodel = \"bge-small-en-v1.5\"\ndefault = true\n",
830        );
831
832        assert_eq!(
833            resolve_project_actor_id(Some(&path)).expect("no error"),
834            None,
835            "a config file with no [actor] section must resolve to None"
836        );
837    }
838}
839
840#[cfg(test)]
841mod no_embeddings_tests {
842    use super::*;
843    use serial_test::serial;
844
845    #[test]
846    fn no_embeddings_clears_both_fields() {
847        let config = RuntimeConfig::no_embeddings();
848        assert_eq!(config.embedding_model, None);
849        assert!(config.additional_embedding_models.is_empty());
850        assert!(
851            configured_embedding_models(&config).is_empty(),
852            "no_embeddings() must yield zero configured embedders"
853        );
854    }
855
856    #[test]
857    #[serial]
858    fn no_embeddings_ignores_additional_env_override() {
859        // no_embeddings() is an unconditional opt-out: even if the caller's
860        // environment sets KHIVE_ADDITIONAL_EMBEDDING_MODELS, the resulting
861        // config must still report zero embedders.
862        std::env::set_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS", "paraphrase");
863        let config = RuntimeConfig::no_embeddings();
864        std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
865
866        assert!(config.additional_embedding_models.is_empty());
867        assert!(configured_embedding_models(&config).is_empty());
868    }
869
870    #[test]
871    #[serial]
872    fn default_computes_additional_models_independently_of_no_embeddings() {
873        // `Default` must keep computing `embedding_model` and
874        // `additional_embedding_models` independently; `no_embeddings()` is a
875        // separate opt-out constructor, not a change to `Default`'s seeding.
876        // The env var is SET here rather than cleared: since Default now ships
877        // no secondary engine, an unset env would make Default and
878        // no_embeddings() indistinguishable and the test would stop
879        // discriminating the thing it exists to discriminate.
880        std::env::set_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS", "paraphrase");
881        let config = RuntimeConfig::default();
882        let buggy_form = RuntimeConfig {
883            embedding_model: None,
884            ..RuntimeConfig::default()
885        };
886        std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
887
888        assert_eq!(
889            config.additional_embedding_models,
890            vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]
891        );
892
893        // Overriding only `embedding_model` via struct-update syntax does not
894        // clear `additional_embedding_models`.
895        assert!(
896            !buggy_form.additional_embedding_models.is_empty(),
897            "Default's independent-field seeding must remain unchanged; \
898             no_embeddings() is the fix, not a change to Default"
899        );
900    }
901
902    #[test]
903    #[serial]
904    fn default_ships_a_single_engine_when_env_unset() {
905        // A secondary engine is embedded on every write and searched on every
906        // read. That cost is opted into, not inherited.
907        std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
908        let config = RuntimeConfig::default();
909
910        assert!(
911            config.additional_embedding_models.is_empty(),
912            "shipped default must register one engine; a second is opt-in via \
913             KHIVE_ADDITIONAL_EMBEDDING_MODELS"
914        );
915        assert_eq!(
916            configured_embedding_models(&config),
917            vec![EmbeddingModel::AllMiniLmL6V2]
918        );
919    }
920}
921
922#[cfg(test)]
923mod configured_embedding_models_order_tests {
924    use super::*;
925
926    /// Issue #1115: the configured engine list must preserve declaration
927    /// order (primary first, then `additional_embedding_models` in order)
928    /// instead of alphabetizing — any consumer that treats the list as
929    /// ordered (e.g. recall fan-out) otherwise gets the wrong primary.
930    #[test]
931    fn preserves_primary_first_then_additional_in_declared_order() {
932        let config = RuntimeConfig {
933            embedding_model: Some(EmbeddingModel::AllMiniLmL6V2),
934            additional_embedding_models: vec![
935                EmbeddingModel::Qwen3Embedding4B,
936                EmbeddingModel::BgeSmallEnV15,
937            ],
938            ..RuntimeConfig::default()
939        };
940
941        assert_eq!(
942            configured_embedding_models(&config),
943            vec![
944                EmbeddingModel::AllMiniLmL6V2,
945                EmbeddingModel::Qwen3Embedding4B,
946                EmbeddingModel::BgeSmallEnV15,
947            ],
948            "order must be primary-first, then additional models as declared, \
949             not alphabetized"
950        );
951    }
952
953    /// A model repeated in both `embedding_model` and `additional_embedding_models`
954    /// must be deduped to a single entry, keeping its first (primary) position.
955    #[test]
956    fn dedupes_model_shared_between_primary_and_additional() {
957        let config = RuntimeConfig {
958            embedding_model: Some(EmbeddingModel::AllMiniLmL6V2),
959            additional_embedding_models: vec![
960                EmbeddingModel::AllMiniLmL6V2,
961                EmbeddingModel::BgeSmallEnV15,
962            ],
963            ..RuntimeConfig::default()
964        };
965
966        assert_eq!(
967            configured_embedding_models(&config),
968            vec![EmbeddingModel::AllMiniLmL6V2, EmbeddingModel::BgeSmallEnV15],
969            "the shared model must appear once, in its primary position"
970        );
971    }
972}