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 /// `khive-pack-git`'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 let additional_embedding_models = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
293 .ok()
294 .map(|s| parse_embedding_model_list(&s))
295 .unwrap_or_else(|| vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]);
296 let packs = std::env::var("KHIVE_PACKS")
297 .ok()
298 .map(|s| parse_pack_list(&s))
299 .filter(|v| !v.is_empty())
300 .unwrap_or_else(|| {
301 vec![
302 "kg",
303 "gtd",
304 "memory",
305 "brain",
306 "comm",
307 "schedule",
308 "knowledge",
309 "session",
310 "git",
311 "code",
312 "workspace",
313 ]
314 .into_iter()
315 .map(String::from)
316 .collect()
317 });
318 let brain_profile = std::env::var("KHIVE_BRAIN_PROFILE")
319 .ok()
320 .filter(|s| !s.trim().is_empty());
321 let actor_id = std::env::var("KHIVE_ACTOR")
322 .ok()
323 .filter(|s| !s.trim().is_empty());
324 Self {
325 db_path,
326 default_namespace: Namespace::local(),
327 embedding_model,
328 additional_embedding_models,
329 gate: Arc::new(AllowAllGate),
330 packs,
331 backend_id: BackendId::main(),
332 brain_profile,
333 visible_namespaces: vec![],
334 allowed_outbound_namespaces: vec![],
335 actor_id,
336 git_write: crate::engine_config::GitWriteSectionConfig::default(),
337 }
338 }
339}
340
341impl RuntimeConfig {
342 /// Build a `RuntimeConfig` with embedding disabled entirely.
343 ///
344 /// `embedding_model` and `additional_embedding_models` are computed
345 /// independently inside [`Default::default`], so `RuntimeConfig {
346 /// embedding_model: None, ..RuntimeConfig::default() }` does NOT produce a
347 /// model-less runtime: `additional_embedding_models` still carries its
348 /// env-driven fallback seed, and the note-write path fans out embedding to
349 /// every registered model regardless of `embedding_model`: so the first
350 /// `memory.remember` on a machine without local model files hard-fails
351 /// instead of degrading to FTS-only.
352 ///
353 /// This constructor clears both fields together and ignores
354 /// `KHIVE_ADDITIONAL_EMBEDDING_MODELS` unconditionally: the caller wants
355 /// zero embedders, not "zero unless the environment disagrees". Use it on
356 /// model-less machines (CI runners, fresh installs without local model
357 /// files) instead of the two-field struct-update form.
358 pub fn no_embeddings() -> Self {
359 Self {
360 embedding_model: None,
361 additional_embedding_models: Vec::new(),
362 ..Self::default()
363 }
364 }
365}
366
367/// Resolve the `--db`/`KHIVE_DB` value into the anchor path used for tier-3
368/// project-local `.khive/config.toml` discovery, mirroring the precedence
369/// `kkernel mcp` and `kkernel exec` use to open the database itself:
370/// `:memory:` has no file to anchor on (`None`); an explicit path anchors on
371/// that path; an unset value falls back to `$HOME/.khive/khive.db`, or
372/// `./.khive/khive.db` when `HOME` is unset.
373///
374/// Always resolves to a concrete anchor (unlike a 2-arm "override the
375/// default?" resolver): when `HOME` is unset this falls back to
376/// `./.khive/khive.db` rather than `None`, deliberately diverging from
377/// `RuntimeConfig::default()`: a caller anchoring config discovery needs a
378/// concrete directory to search even without `HOME`.
379pub fn resolve_db_anchor(db: Option<&str>) -> Option<std::path::PathBuf> {
380 match db {
381 Some(":memory:") => None,
382 Some(path) => Some(std::path::PathBuf::from(path)),
383 None => {
384 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
385 Some(std::path::PathBuf::from(format!("{home}/.khive/khive.db")))
386 }
387 }
388}
389
390/// Assert that a resolved `db_path`: which `compute_config_id` folds into a
391/// process's `config_id`: agrees with what [`resolve_db_anchor`] derives from
392/// the same raw `--db`/`KHIVE_DB` input.
393///
394/// This compatibility entry point preserves the raw-string API. Construction
395/// paths that already captured the anchor should call
396/// [`assert_captured_db_anchor_consistent`] so they do not re-read mutable
397/// process environment.
398pub fn assert_db_anchor_consistent(
399 resolved_db_path: Option<&std::path::Path>,
400 args_db: Option<&str>,
401) -> anyhow::Result<()> {
402 let db_anchor = resolve_db_anchor(args_db);
403 assert_captured_db_anchor_consistent(resolved_db_path, db_anchor.as_deref())
404}
405
406/// Assert that a resolved `db_path`: which `compute_config_id` folds into a
407/// process's `config_id`: agrees with the database anchor captured from the
408/// same `--db`/`KHIVE_DB` input at the construction boundary.
409///
410/// Guards against a construction path recomputing `db_path` independently of
411/// `resolve_db_anchor`: left unchecked, that would silently desync `config_id`
412/// from a daemon or peer sharing the same database instead of failing loud.
413/// The caller passes the captured anchor so validation never re-reads mutable
414/// process environment. Inert (`Ok(())`) when the anchor itself is `None` (the
415/// `:memory:` sentinel) since there is nothing to compare against.
416pub fn assert_captured_db_anchor_consistent(
417 resolved_db_path: Option<&std::path::Path>,
418 db_anchor: Option<&std::path::Path>,
419) -> anyhow::Result<()> {
420 let Some(anchor) = db_anchor else {
421 return Ok(());
422 };
423 if resolved_db_path != Some(anchor) {
424 anyhow::bail!(
425 "db-path resolution drift at server construction: resolved db_path {:?} \
426 does not match the canonical anchor {:?} computed by resolve_db_anchor \
427 from the same --db input; this construction path likely recomputed the \
428 db path independently instead of routing through the shared resolver, \
429 which would desynchronize config_id from other processes sharing the \
430 same database",
431 resolved_db_path,
432 anchor
433 );
434 }
435 Ok(())
436}
437
438/// Resolve the per-connection attribution actor from the project/cwd-anchored
439/// config tier, independently of the database-anchored config load that
440/// governs `config_id`.
441///
442/// The database-anchored config load keeps `config_id` coherent between a
443/// short-lived client and a long-running daemon sharing one database, but
444/// when many per-project connections share one database under a single
445/// `HOME` (the daemon-multiplexed fleet case), that shared config carries no
446/// `[actor]` block, so every connection's write-stamp attribution collapses
447/// to the default identity.
448///
449/// This performs a separate, cwd-anchored lookup (`db_path: None`) and reads
450/// only `[actor].id`: it must not perturb `config_id` or `default_namespace`,
451/// which remain governed exclusively by the database-anchored load.
452///
453/// `config_path` is the same explicit `--config`/`KHIVE_CONFIG` override the
454/// caller's database-anchored load receives, so an explicit override wins
455/// here too.
456///
457/// Returns `Ok(None)` when no project-anchored config exists, or it exists
458/// but carries no non-empty `[actor].id`: callers fall through to their own
459/// env/anonymous tiers in that case.
460pub fn resolve_project_actor_id(
461 config_path: Option<&std::path::Path>,
462) -> Result<Option<String>, crate::engine_config::ConfigError> {
463 let khive_cfg = crate::engine_config::KhiveConfig::load_with_home_fallback(config_path, None)?;
464 Ok(khive_cfg
465 .and_then(|cfg| cfg.actor.id)
466 .filter(|s| !s.trim().is_empty()))
467}
468
469// ---- Embedding model helpers ----
470
471/// Sanitize an embedding model name into a valid SQL table suffix.
472/// e.g. `bge-small-en-v1.5` -> `bge_small_en_v1_5`
473pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
474 sanitize_key(&model.to_string())
475}
476
477pub(crate) fn sanitize_key(s: &str) -> String {
478 s.chars()
479 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
480 .collect()
481}
482
483pub(crate) fn build_embedder_registry(
484 config: &RuntimeConfig,
485) -> (crate::embedder_registry::EmbedderRegistry, Arc<str>) {
486 use crate::embedder_registry::{EmbedderRegistry, LatticeEmbedderProvider};
487 let mut registry = EmbedderRegistry::new();
488 for model in configured_embedding_models(config) {
489 registry.register(LatticeEmbedderProvider::new(model));
490 }
491 let default_embedder_name = config
492 .embedding_model
493 .map(|model| Arc::<str>::from(model.to_string()))
494 .unwrap_or_else(|| Arc::<str>::from(""));
495 (registry, default_embedder_name)
496}
497
498fn configured_embedding_models(config: &RuntimeConfig) -> Vec<EmbeddingModel> {
499 let mut models = Vec::new();
500 if let Some(model) = config.embedding_model {
501 models.push(model);
502 }
503 models.extend(config.additional_embedding_models.iter().copied());
504 models.sort_by_key(|model| model.to_string());
505 models.dedup();
506 models
507}
508
509pub(crate) fn register_configured_embedding_models(
510 backend: &StorageBackend,
511 config: &RuntimeConfig,
512) -> RuntimeResult<()> {
513 for model in configured_embedding_models(config) {
514 backend.register_embedding_model(
515 &model.to_string(),
516 model.model_id(),
517 model.key_version(),
518 model.dimensions() as u32,
519 )?;
520 }
521 Ok(())
522}
523
524/// Build a `RuntimeConfig` from a parsed `KhiveConfig`.
525///
526/// For each `[[engines]]` entry:
527/// - The engine flagged `default = true` becomes `RuntimeConfig::embedding_model`.
528/// - All other engines become `RuntimeConfig::additional_embedding_models`.
529///
530/// Model name validity is checked here: any engine whose `model` field cannot
531/// be parsed via `parse_embedding_model_alias` is skipped with a warning.
532///
533/// If `khive_cfg.engines` is empty, the returned `RuntimeConfig` uses the
534/// env-var-derived defaults from `RuntimeConfig::default()`.
535///
536/// When both a config file and `KHIVE_EMBEDDING_MODEL` env var are present,
537/// the caller is responsible for emitting a warning that env vars are overridden.
538/// This function purely converts `KhiveConfig` to `RuntimeConfig` fields.
539pub fn runtime_config_from_khive_config(
540 khive_cfg: &crate::engine_config::KhiveConfig,
541 base: RuntimeConfig,
542) -> RuntimeConfig {
543 // `[actor] id` never becomes the storage namespace (writes always pin to
544 // `local`); it only widens the read visible-set below.
545 let default_namespace = base.default_namespace.clone();
546
547 // base.brain_profile must carry only the explicit CLI tier, never an env
548 // value: env sits below toml in precedence and is applied later by the MCP resolver.
549 let brain_profile = base.brain_profile.clone().or_else(|| {
550 khive_cfg
551 .runtime
552 .brain_profile
553 .clone()
554 .filter(|s| !s.trim().is_empty())
555 });
556
557 let visible_namespaces: Vec<Namespace> = khive_cfg
558 .actor
559 .visible_namespaces
560 .as_deref()
561 .unwrap_or_default()
562 .iter()
563 .filter_map(|s| match Namespace::parse(s) {
564 Ok(ns) => Some(ns),
565 Err(e) => {
566 tracing::warn!(ns = %s, error = %e, "actor.visible_namespaces: invalid namespace; skipped");
567 None
568 }
569 })
570 .collect();
571
572 // Fold actor.id's namespace into visible_namespaces so default reads widen
573 // to {local} ∪ {actor namespace}; skipped when it parses to `local` (would
574 // duplicate the primary namespace already minted) or is already present.
575 let visible_namespaces = if let Some(id) = khive_cfg.actor.id.as_deref() {
576 match Namespace::parse(id) {
577 Ok(actor_ns) if actor_ns != Namespace::local() => {
578 let mut v = visible_namespaces;
579 if !v.contains(&actor_ns) {
580 v.push(actor_ns);
581 }
582 v
583 }
584 _ => visible_namespaces,
585 }
586 } else {
587 visible_namespaces
588 };
589
590 // KhiveConfig::validate() guarantees these are valid Namespace strings, so
591 // parse failures here are unreachable for validated configs; filter_map+warn
592 // guards against a validation bug panicking instead.
593 let allowed_outbound_namespaces: Vec<Namespace> = khive_cfg
594 .actor
595 .allowed_outbound_namespaces
596 .iter()
597 .filter_map(|s| match Namespace::parse(s) {
598 Ok(ns) => Some(ns),
599 Err(e) => {
600 tracing::warn!(ns = %s, error = %e, "actor.allowed_outbound_namespaces: invalid namespace; skipped");
601 None
602 }
603 })
604 .collect();
605
606 // Precedence: TOML `[actor] id` > `base.actor_id` (env/CLI-resolved) >
607 // anonymous. Falls back to `base.actor_id` rather than `None` when
608 // `[actor] id` is absent: otherwise an env-resolved actor like
609 // `KHIVE_ACTOR` is silently dropped whenever a project config exists
610 // without an `[actor]` block.
611 let actor_id = khive_cfg
612 .actor
613 .id
614 .clone()
615 .filter(|s| !s.trim().is_empty())
616 .or_else(|| base.actor_id.clone());
617
618 let git_write = khive_cfg.git_write.clone();
619
620 if khive_cfg.engines.is_empty() {
621 return RuntimeConfig {
622 default_namespace,
623 brain_profile,
624 visible_namespaces,
625 allowed_outbound_namespaces,
626 actor_id,
627 git_write,
628 ..base
629 };
630 }
631
632 let mut embedding_model: Option<EmbeddingModel> = None;
633 let mut additional: Vec<EmbeddingModel> = Vec::new();
634
635 for engine in &khive_cfg.engines {
636 match parse_embedding_model_alias(&engine.model) {
637 Some(model) => {
638 if engine.default {
639 embedding_model = Some(model);
640 } else {
641 additional.push(model);
642 }
643 }
644 None => {
645 tracing::warn!(
646 engine = %engine.name,
647 model = %engine.model,
648 "engine config: unknown model name; engine will be skipped"
649 );
650 }
651 }
652 }
653
654 RuntimeConfig {
655 embedding_model,
656 additional_embedding_models: additional,
657 default_namespace,
658 brain_profile,
659 visible_namespaces,
660 allowed_outbound_namespaces,
661 actor_id,
662 git_write,
663 ..base
664 }
665}
666
667/// Parse a comma- or whitespace-separated list of embedding model names.
668fn parse_embedding_model_list(s: &str) -> Vec<EmbeddingModel> {
669 parse_pack_list(s)
670 .into_iter()
671 .filter_map(|raw| {
672 let parsed = parse_embedding_model_alias(&raw);
673 if parsed.is_none() && !raw.trim().is_empty() {
674 tracing::warn!(
675 model = %raw,
676 "KHIVE_ADDITIONAL_EMBEDDING_MODELS contains unknown model name; ignored. \
677 Valid forms: short alias like 'paraphrase' or a fully-qualified key \
678 from lattice_embed::EmbeddingModel::from_str."
679 );
680 }
681 parsed
682 })
683 .collect()
684}
685
686pub(crate) fn parse_embedding_model_alias(name: &str) -> Option<EmbeddingModel> {
687 let normalized = name.trim().to_ascii_lowercase().replace('_', "-");
688 match normalized.as_str() {
689 "paraphrase" => Some(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2),
690 _ => normalized.parse().ok(),
691 }
692}
693
694#[cfg(test)]
695mod resolve_db_anchor_tests {
696 use super::resolve_db_anchor;
697
698 #[test]
699 fn memory_sentinel_maps_to_none() {
700 assert_eq!(resolve_db_anchor(Some(":memory:")), None);
701 }
702
703 #[test]
704 fn explicit_path_maps_to_some() {
705 assert_eq!(
706 resolve_db_anchor(Some("/tmp/khive-anchor-test.db")),
707 Some(std::path::PathBuf::from("/tmp/khive-anchor-test.db"))
708 );
709 }
710
711 #[test]
712 fn absent_maps_to_home_default() {
713 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
714 let expected = std::path::PathBuf::from(format!("{home}/.khive/khive.db"));
715 assert_eq!(resolve_db_anchor(None), Some(expected));
716 }
717}
718
719#[cfg(test)]
720mod assert_db_anchor_consistent_tests {
721 use super::{assert_captured_db_anchor_consistent, resolve_db_anchor};
722 use crate::assert_db_anchor_consistent;
723
724 #[test]
725 fn diverging_db_path_is_rejected_naming_both_paths() {
726 let args_db = "/tmp/khive-anchor-guard-real.db";
727 let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
728 let wrong = std::path::PathBuf::from("/tmp/khive-anchor-guard-wrong.db");
729
730 let err =
731 assert_captured_db_anchor_consistent(Some(wrong.as_path()), Some(anchor.as_path()))
732 .expect_err("a resolved db_path diverging from the anchor must be rejected");
733
734 let msg = err.to_string();
735 assert!(
736 msg.contains(&wrong.display().to_string()),
737 "error must name the resolved (wrong) path: {msg}"
738 );
739 assert!(
740 msg.contains(&anchor.display().to_string()),
741 "error must name the canonical anchor path: {msg}"
742 );
743 }
744
745 #[test]
746 fn matching_explicit_db_path_passes() {
747 let args_db = "/tmp/khive-anchor-guard-consistent.db";
748 let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
749 assert!(assert_captured_db_anchor_consistent(
750 Some(anchor.as_path()),
751 Some(anchor.as_path())
752 )
753 .is_ok());
754 }
755
756 #[test]
757 fn memory_sentinel_anchor_is_inert() {
758 // `resolve_db_anchor(":memory:")` yields `None` — there is no canonical
759 // path to assert against, so the guard passes regardless of what
760 // `resolved_db_path` happens to carry.
761 let bogus = std::path::PathBuf::from("/tmp/should-not-matter.db");
762 assert!(assert_captured_db_anchor_consistent(Some(bogus.as_path()), None).is_ok());
763 assert!(assert_captured_db_anchor_consistent(None, None).is_ok());
764 }
765
766 #[test]
767 fn normal_boot_with_db_unset_passes_silently() {
768 // Mirrors a normal boot with `--db` unset: `resolve_db_anchor(None)`
769 // always resolves to `Some(..)` (HOME-set or -unset both produce a
770 // concrete anchor), so a runtime whose resolved `db_path` matches
771 // passes silently.
772 let anchor = resolve_db_anchor(None);
773 assert!(assert_captured_db_anchor_consistent(anchor.as_deref(), anchor.as_deref()).is_ok());
774 }
775
776 #[test]
777 fn public_compatibility_wrapper_accepts_path_and_memory_sentinel() {
778 let args_db = "/tmp/khive-anchor-guard-public-api.db";
779 let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
780 assert!(assert_db_anchor_consistent(Some(anchor.as_path()), Some(args_db)).is_ok());
781
782 let unrelated = std::path::Path::new("/tmp/khive-anchor-guard-unrelated.db");
783 assert!(assert_db_anchor_consistent(Some(unrelated), Some(":memory:")).is_ok());
784 }
785}
786
787#[cfg(test)]
788mod resolve_project_actor_id_tests {
789 use super::resolve_project_actor_id;
790
791 fn write_toml(dir: &tempfile::TempDir, body: &str) -> std::path::PathBuf {
792 let path = dir.path().join("config.toml");
793 std::fs::write(&path, body).expect("write config.toml");
794 path
795 }
796
797 #[test]
798 fn extracts_non_empty_actor_id_from_explicit_path() {
799 let dir = tempfile::tempdir().expect("tempdir");
800 let path = write_toml(&dir, "[actor]\nid = \"lambda:explicit-actor\"\n");
801
802 assert_eq!(
803 resolve_project_actor_id(Some(&path)).expect("no error"),
804 Some("lambda:explicit-actor".to_string())
805 );
806 }
807
808 #[test]
809 fn returns_none_for_missing_explicit_path() {
810 let missing = std::path::PathBuf::from("/nonexistent/khive-project-actor-test/config.toml");
811 assert_eq!(
812 resolve_project_actor_id(Some(&missing)).expect("no error"),
813 None,
814 "a nonexistent explicit path must resolve to None, not an error"
815 );
816 }
817
818 #[test]
819 fn propagates_load_error_for_invalid_actor_id() {
820 // `KhiveConfig::load`'s `validate()` rejects an empty `[actor] id` before
821 // the emptiness filter in `resolve_project_actor_id` ever sees it; this
822 // asserts the error surfaces rather than being swallowed into `Ok(None)`.
823 let dir = tempfile::tempdir().expect("tempdir");
824 let path = write_toml(&dir, "[actor]\nid = \"\"\n");
825
826 let err = resolve_project_actor_id(Some(&path)).expect_err("invalid actor.id must error");
827 assert!(
828 matches!(
829 err,
830 crate::engine_config::ConfigError::InvalidActorId { .. }
831 ),
832 "expected InvalidActorId, got {err:?}"
833 );
834 }
835
836 #[test]
837 fn returns_none_when_config_has_no_actor_section() {
838 let dir = tempfile::tempdir().expect("tempdir");
839 let path = write_toml(
840 &dir,
841 "[[engines]]\nname = \"primary\"\nmodel = \"bge-small-en-v1.5\"\ndefault = true\n",
842 );
843
844 assert_eq!(
845 resolve_project_actor_id(Some(&path)).expect("no error"),
846 None,
847 "a config file with no [actor] section must resolve to None"
848 );
849 }
850}
851
852#[cfg(test)]
853mod no_embeddings_tests {
854 use super::*;
855 use serial_test::serial;
856
857 #[test]
858 fn no_embeddings_clears_both_fields() {
859 let config = RuntimeConfig::no_embeddings();
860 assert_eq!(config.embedding_model, None);
861 assert!(config.additional_embedding_models.is_empty());
862 assert!(
863 configured_embedding_models(&config).is_empty(),
864 "no_embeddings() must yield zero configured embedders"
865 );
866 }
867
868 #[test]
869 #[serial]
870 fn no_embeddings_ignores_additional_env_override() {
871 // no_embeddings() is an unconditional opt-out: even if the caller's
872 // environment sets KHIVE_ADDITIONAL_EMBEDDING_MODELS, the resulting
873 // config must still report zero embedders.
874 std::env::set_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS", "paraphrase");
875 let config = RuntimeConfig::no_embeddings();
876 std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
877
878 assert!(config.additional_embedding_models.is_empty());
879 assert!(configured_embedding_models(&config).is_empty());
880 }
881
882 #[test]
883 #[serial]
884 fn default_still_seeds_additional_models_when_env_unset() {
885 // `Default` must keep computing `embedding_model` and
886 // `additional_embedding_models` independently; `no_embeddings()` is a
887 // separate opt-out constructor, not a change to `Default`'s seeding.
888 std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
889 let config = RuntimeConfig::default();
890
891 assert_eq!(
892 config.additional_embedding_models,
893 vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]
894 );
895
896 // Overriding only `embedding_model` via struct-update syntax does not
897 // clear `additional_embedding_models`.
898 let buggy_form = RuntimeConfig {
899 embedding_model: None,
900 ..RuntimeConfig::default()
901 };
902 assert!(
903 !buggy_form.additional_embedding_models.is_empty(),
904 "Default's independent-field seeding must remain unchanged; \
905 no_embeddings() is the fix, not a change to Default"
906 );
907 }
908}