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 the `main` backend id 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(String);
26
27/// Validation error returned when a backend identifier is rejected.
28#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
29pub enum BackendIdError {
30    /// The supplied identifier was empty or contained only whitespace.
31    #[error("backend id must not be empty or whitespace-only")]
32    Empty,
33}
34
35impl BackendId {
36    /// The default single-backend name.
37    pub const MAIN: &'static str = "main";
38
39    /// Parse a nonempty backend identifier.
40    pub fn parse(name: impl Into<String>) -> Result<Self, BackendIdError> {
41        let name = name.into();
42        if name.trim().is_empty() {
43            return Err(BackendIdError::Empty);
44        }
45        Ok(Self(name))
46    }
47
48    /// The default `main` backend id.
49    pub fn main() -> Self {
50        Self(Self::MAIN.to_string())
51    }
52
53    /// Return the backend name as a `&str`.
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57}
58
59impl TryFrom<String> for BackendId {
60    type Error = BackendIdError;
61
62    fn try_from(value: String) -> Result<Self, Self::Error> {
63        Self::parse(value)
64    }
65}
66
67impl TryFrom<&str> for BackendId {
68    type Error = BackendIdError;
69
70    fn try_from(value: &str) -> Result<Self, Self::Error> {
71        Self::parse(value)
72    }
73}
74
75impl std::str::FromStr for BackendId {
76    type Err = BackendIdError;
77
78    fn from_str(value: &str) -> Result<Self, Self::Err> {
79        Self::parse(value)
80    }
81}
82
83impl std::fmt::Display for BackendId {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(&self.0)
86    }
87}
88
89#[cfg(test)]
90mod backend_id_tests {
91    use super::BackendId;
92
93    #[test]
94    fn empty_and_whitespace_only_backend_ids_are_rejected() {
95        for invalid in ["", " ", "\t\n"] {
96            assert!(
97                BackendId::parse(invalid).is_err(),
98                "backend id {invalid:?} must be rejected"
99            );
100        }
101    }
102
103    #[test]
104    fn nonempty_backend_id_round_trips() {
105        let id = BackendId::parse("archive").expect("valid backend id");
106        assert_eq!(id.as_str(), "archive");
107        assert_eq!(id.to_string(), "archive");
108    }
109}
110
111// ---- Sealed token ----
112
113mod private {
114    #[derive(Clone, Debug)]
115    pub(crate) struct Sealed;
116}
117
118/// Authorization proof that a caller is permitted to access a specific namespace.
119///
120/// Created by [`crate::VerbRegistry::dispatch`] after the gate approves the request.
121/// The sealed inner field prevents external code from constructing a token
122/// without going through the authorization path.
123///
124/// The `namespace` field is the **write namespace**: all records created via
125/// this token land in that namespace. `visible` is the **read visibility set**:
126/// list/search/get operations will return records from any namespace in this
127/// set. The write namespace is always a member of the visible set.
128///
129/// Single-namespace behaviour (backward-compatible default): `visible` contains
130/// exactly `[namespace]` — identical to the old strict-equality checks.
131#[derive(Clone, Debug)]
132pub struct NamespaceToken {
133    namespace: Namespace,
134    gate_namespace: Namespace,
135    gate_explicit_namespace: Option<String>,
136    request_id: Option<u64>,
137    visible: Vec<Namespace>,
138    actor: ActorRef,
139    process_ref: Option<String>,
140    _sealed: private::Sealed,
141}
142
143impl NamespaceToken {
144    /// Mint an authorized token with an extended visibility set.
145    ///
146    /// `extra_visible` lists namespaces beyond the primary that the token may
147    /// read. The primary namespace is always included in the visible set
148    /// regardless of what `extra_visible` contains. Duplicates are removed.
149    pub(crate) fn mint_with_visibility(
150        namespace: Namespace,
151        extra_visible: Vec<Namespace>,
152        actor: ActorRef,
153    ) -> Self {
154        let mut visible = vec![namespace.clone()];
155        for ns in extra_visible {
156            if !visible.contains(&ns) {
157                visible.push(ns);
158            }
159        }
160        debug_assert!(!visible.is_empty(), "visible set must be non-empty");
161        Self {
162            gate_namespace: namespace.clone(),
163            gate_explicit_namespace: None,
164            request_id: None,
165            namespace,
166            visible,
167            actor,
168            process_ref: None,
169            _sealed: private::Sealed,
170        }
171    }
172
173    /// Mint an authorized token. Only callable from within `khive-runtime`.
174    ///
175    /// The visible set defaults to `[namespace]` — backward-compatible with
176    /// single-namespace enforcement.
177    pub(crate) fn mint_authorized(namespace: Namespace, actor: ActorRef) -> Self {
178        Self::mint_with_visibility(namespace, vec![], actor)
179    }
180
181    /// Convenience constructor for the local namespace with an anonymous actor.
182    ///
183    /// Only callable from within `khive-runtime`. External callers must use
184    /// [`KhiveRuntime::authorize`] to mint tokens.
185    // Used only in #[cfg(test)] blocks within this crate's src/ files.
186    #[allow(dead_code)]
187    pub(crate) fn local() -> Self {
188        Self::mint_authorized(Namespace::local(), ActorRef::anonymous())
189    }
190
191    /// Convenience constructor for a specific namespace with an anonymous actor.
192    ///
193    /// Only callable from within `khive-runtime`. External callers must use
194    /// [`KhiveRuntime::authorize`] to mint tokens.
195    // Used only in #[cfg(test)] blocks within this crate's src/ files.
196    #[allow(dead_code)]
197    pub(crate) fn for_namespace(ns: Namespace) -> Self {
198        Self::mint_authorized(ns, ActorRef::anonymous())
199    }
200
201    /// Return the write namespace this token authorises.
202    ///
203    /// All records created via this token land in this namespace.
204    pub fn namespace(&self) -> &Namespace {
205        &self.namespace
206    }
207
208    /// Return the namespace used by the originating dispatch's Gate check.
209    /// It can differ from the primary storage namespace on an implicit request.
210    /// Tokens minted directly, or reminted by `with_namespace`, use their primary.
211    pub fn gate_namespace(&self) -> &Namespace {
212        &self.gate_namespace
213    }
214
215    pub(crate) fn with_gate_namespace(mut self, namespace: Namespace) -> Self {
216        self.gate_namespace = namespace;
217        self
218    }
219
220    /// The exact submitted namespace argument, if the caller supplied one.
221    /// Pack handlers normally receive params after dispatch strips this key.
222    pub(crate) fn gate_explicit_namespace(&self) -> Option<&str> {
223        self.gate_explicit_namespace.as_deref()
224    }
225
226    pub(crate) fn with_gate_explicit_namespace(mut self, namespace: Option<String>) -> Self {
227        self.gate_explicit_namespace = namespace;
228        self
229    }
230
231    /// Correlates handler-internal Gate consultations with their dispatch audit.
232    pub(crate) fn request_id(&self) -> Option<u64> {
233        self.request_id
234    }
235
236    pub(crate) fn with_request_id(mut self, request_id: Option<u64>) -> Self {
237        self.request_id = request_id;
238        self
239    }
240
241    /// Return the read-visibility set.
242    ///
243    /// List, search, and get operations must accept records whose namespace is
244    /// a member of this set. The write namespace is always included.
245    pub fn visible_namespaces(&self) -> &[Namespace] {
246        &self.visible
247    }
248
249    /// Return a deduplicated list of visible namespace strings (borrowed).
250    ///
251    /// Convenience for passing directly to storage layer filters.
252    pub fn visible_namespace_strs(&self) -> Vec<&str> {
253        self.visible.iter().map(|ns| ns.as_str()).collect()
254    }
255
256    /// Return the actor reference embedded in this token.
257    pub fn actor(&self) -> &ActorRef {
258        &self.actor
259    }
260
261    /// Return the originating process's opaque attribution reference, when set.
262    ///
263    /// This is request metadata only. It never participates in gate checks,
264    /// namespace visibility, or actor identity.
265    pub fn process_ref(&self) -> Option<&str> {
266        self.process_ref.as_deref()
267    }
268
269    pub(crate) fn with_process_ref(mut self, process_ref: Option<String>) -> Self {
270        self.process_ref = process_ref;
271        self
272    }
273
274    /// Return a new token with the same actor but a different namespace.
275    /// The Gate namespace metadata is reset to the new primary namespace.
276    ///
277    /// The visible set is replaced with `[ns]`: this is a full read+write token
278    /// for `ns`, not a type-enforced write-only or append-only capability. It is
279    /// a capability-transfer primitive, not a policy gate: callers must enforce
280    /// any ACL check before calling this and use the minted token only within
281    /// the intended narrow scope (e.g. a single `create_note` call). A future
282    /// security model should replace this pattern with a type-enforced
283    /// append-only capability that goes through the Gate.
284    pub fn with_namespace(&self, ns: Namespace) -> Self {
285        Self::mint_authorized(ns, self.actor.clone()).with_process_ref(self.process_ref.clone())
286    }
287}
288
289/// Read the optional request-origin process reference without normalization.
290///
291/// A non-Unicode environment value cannot be represented in JSON and is
292/// treated as absent after emitting a warning that does not expose its bytes.
293pub fn process_ref_from_env() -> Option<String> {
294    match std::env::var("KHIVE_PROCESS_REF") {
295        Ok(value) => Some(value),
296        Err(std::env::VarError::NotPresent) => None,
297        Err(std::env::VarError::NotUnicode(_)) => {
298            tracing::warn!(
299                "KHIVE_PROCESS_REF is not valid Unicode and cannot be represented in request metadata"
300            );
301            None
302        }
303    }
304}
305
306// ---- RuntimeConfig ----
307
308/// Runtime configuration.
309///
310/// The `db_path` field remains for backward compatibility and as input to the
311/// supported async khive-mcp/kkernel host builders. Direct backend assembly is
312/// reserved for an already-coordinated database; use
313/// [`crate::KhiveRuntime::from_prepared_backend`] when that precondition has
314/// been established. `embedding_model` remains as the primary-model config
315/// shorthand beside the provider registry.
316#[derive(Clone, Debug)]
317pub struct RuntimeConfig {
318    pub mounts: Vec<crate::mount_config::MountConfig>,
319
320    /// Path to the SQLite database file. `None` = in-memory (tests).
321    ///
322    /// Production boot passes this value to the async khive-mcp/kkernel host
323    /// builders, which coordinate V21 before constructing runtimes. Tests and
324    /// already-current single-backend callers may still use it directly.
325    pub db_path: Option<std::path::PathBuf>,
326    /// Namespace used when no explicit namespace is provided.
327    pub default_namespace: Namespace,
328    /// Local embedding model. `None` alone does not disable embedding: setting
329    /// only this field to `None` while `additional_embedding_models` is
330    /// non-empty still registers those models. Both `embedding_model` and
331    /// `additional_embedding_models` must be empty to disable built-in
332    /// embedding model registration, at which point `hybrid_search` falls back
333    /// to text-only. Use [`RuntimeConfig::no_embeddings`] to clear both fields
334    /// together — it is the canonical constructor for this. Custom embedder
335    /// providers registered later by packs are not affected by this field.
336    ///
337    /// Deprecated: embedding engines move to a per-pack `EmbedderRegistry`.
338    /// This field persists for backward compatibility until the embedder registry
339    /// is fully plumbed.
340    pub embedding_model: Option<EmbeddingModel>,
341    /// Additional embedding models to make available by request name.
342    ///
343    /// `embedding_model` remains the default used by existing `embed()` and
344    /// `embed_batch()` callers. This list adds non-default models that can be
345    /// selected with `embedder(name)`, `embed_with_model(...)`, memory
346    /// `remember.embedding_model`, and memory `recall.embedding_model`.
347    pub additional_embedding_models: Vec<EmbeddingModel>,
348    /// Authorization gate consulted before each verb dispatch.
349    /// Default: `AllowAllGate` (permissive). For production policy enforcement,
350    /// plug in a Rego- or capability-witness-backed impl.
351    pub gate: GateRef,
352    /// Names of packs the transport layer should register into the VerbRegistry.
353    /// The transport layer (e.g. `khive-mcp`) reads this list and instantiates
354    /// the matching concrete pack types. Unknown names are reported as errors
355    /// by the transport, not silently ignored.
356    /// Defaults to the shipped production set returned by
357    /// [`RuntimeConfig::built_in_packs`].
358    pub packs: Vec<String>,
359    /// Resolved aggregate admission budget for resident digest-verified blob
360    /// buffers (ADR-160 D3), in raw bytes.
361    ///
362    /// `[runtime].blob_hydration_bytes` may raise or lower the built-in 256 MiB
363    /// value, but config validation guarantees enough room for one maximum-size
364    /// whole-blob read.
365    pub blob_hydration_bytes: u64,
366    /// Identifies this runtime's backend in a multi-backend deployment.
367    ///
368    /// Set by the boot path when constructing per-pack runtimes from `khive.toml`.
369    /// Single-backend deployments use the default `BackendId::MAIN`.
370    pub backend_id: BackendId,
371    /// Brain profile to use for `memory.feedback` / `knowledge.feedback` and
372    /// recall-time score boosting (ADR-035 §Brain profile configuration).
373    ///
374    /// Resolution order (highest to lowest, ADR-035): CLI flag, then
375    /// `runtime.brain_profile` in project/global `khive.toml`, then the
376    /// `KHIVE_BRAIN_PROFILE` env var as fallback default. Callers must keep
377    /// env OUT of the base config they pass in (see `khive-mcp` serve.rs).
378    /// 1. `--brain-profile` CLI flag (explicit only)
379    /// 2. Namespace-bound profile resolved via `brain.resolve` at feedback time
380    /// 3. Pack-local global tuning prior (default fallback)
381    pub brain_profile: Option<String>,
382    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
383    ///
384    /// OSS dispatch widens the DEFAULT multi-record read scope to
385    /// `['local'] ∪ visible_namespaces`. Writes remain pinned to `'local'`.
386    /// An explicit `namespace=` request param is a precise single-namespace
387    /// escape and is not widened. Populated from `actor.visible_namespaces`
388    /// in `khive.toml`.
389    pub visible_namespaces: Vec<Namespace>,
390    /// Namespaces this actor's comm.send/reply may deliver messages INTO
391    /// (outbound, sender-side). Populated from `actor.allowed_outbound_namespaces`
392    /// in `khive.toml`. Empty by default — cross-namespace delivery denied
393    /// unless explicitly declared. The comm handler uses an ordinary
394    /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
395    /// the token itself is NOT type-enforced write-only. The recipient-side
396    /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
397    /// a future cloud-path authorization ADR (not yet written).
398    pub allowed_outbound_namespaces: Vec<Namespace>,
399    /// Configured actor identity label (ADR-057). Populated from `[actor] id` in
400    /// `khive.toml`. When `Some`, `authorize()` mints tokens carrying this actor
401    /// label so that `comm.inbox` filters by `to_actor` instead of falling back to
402    /// the party-line "local" behavior. When `None` (default), tokens carry
403    /// `ActorRef::anonymous()` and inbox is scoped to party-line messages —
404    /// those addressed to `"local"` or carrying no `to_actor` stamp.
405    pub actor_id: Option<String>,
406    /// Resolved `[brain]` policy from the serving process's configuration.
407    pub brain: crate::engine_config::BrainSectionConfig,
408    /// Resolved `[git_write]` policy allowlist (ADR-108 Amendment), populated
409    /// from `khive.toml`'s `[[git_write.allowed]]` entries by
410    /// [`runtime_config_from_khive_config`]. Threaded through so
411    /// `khive-pack-git`'s write-verb handlers read an already-resolved policy
412    /// instead of re-running config discovery (which would ignore an
413    /// explicit `--config` path not also exported as `KHIVE_CONFIG`).
414    pub git_write: crate::engine_config::GitWriteSectionConfig,
415    /// Resolved `[exec]` sandbox section (ADR-181), threaded through like
416    /// `git_write` so the exec pack reads an already-resolved config.
417    pub exec: crate::engine_config::ExecSectionConfig,
418    /// Resolved carrier and failure policy for telemetry emission.
419    pub telemetry: crate::telemetry_config::TelemetryConfig,
420    /// Resolved rendering timezone (ADR-169), consumed today by date-only
421    /// `parse_due` anchoring. Populated from `[display] timezone` in
422    /// `khive.toml` by [`runtime_config_from_khive_config`]; when absent,
423    /// resolves once to the host's local IANA zone (falling back to UTC when
424    /// the host zone cannot be determined) via [`resolve_default_display_timezone`].
425    pub display_timezone: chrono_tz::Tz,
426    /// Events-daemon split (ADR-170). `None` = legacy behavior: events persist
427    /// in the main store. `Some` routes event persistence to the events database —
428    /// forwarded over the events daemon socket in daemon deployments, opened
429    /// directly in embedded/one-shot contexts. Populated by the transport
430    /// hosts (khive-mcp serve, kkernel exec); tests and in-memory runtimes
431    /// leave it `None`.
432    pub events_split: Option<crate::events_split::EventsSplitConfig>,
433    /// Resolved `[web]` policy (ADR-175 Amendment 1), threaded through like
434    /// `exec`/`git_write` so `khive-pack-web` reads an already-resolved config.
435    pub web: crate::engine_config::WebSectionConfig,
436}
437
438/// Parse a comma- or whitespace-separated pack list from a single string.
439///
440/// Empty entries are dropped, surrounding whitespace is trimmed.
441pub fn parse_pack_list(s: &str) -> Vec<String> {
442    s.split(|c: char| c == ',' || c.is_whitespace())
443        .map(str::trim)
444        .filter(|s| !s.is_empty())
445        .map(str::to_owned)
446        .collect()
447}
448
449/// Interpret the construction-time `KHIVE_ANN_FRESH_TAIL` value.
450///
451/// Preserve the escape hatch's existing semantics while moving its read out of
452/// request serving: only `0` disables; unset and every other value enable.
453fn ann_fresh_tail_enabled_from_value(value: Option<&str>) -> bool {
454    value != Some("0")
455}
456
457/// Sample ADR-118's fresh-tail escape hatch for runtime construction.
458///
459/// Callers must retain the returned value for the runtime's lifetime instead
460/// of re-reading the environment on a serving path. Only the exact value `0`
461/// disables; unset and every other value enable.
462pub fn ann_fresh_tail_enabled_from_env() -> bool {
463    let value = std::env::var("KHIVE_ANN_FRESH_TAIL").ok();
464    ann_fresh_tail_enabled_from_value(value.as_deref())
465}
466
467/// Resolve the host's local IANA zone name once, for [`RuntimeConfig`]'s
468/// default `display_timezone` (ADR-169). Falls back to UTC when the host
469/// zone cannot be determined (e.g. `TZ`/`/etc/localtime` unreadable) or does
470/// not parse as a known `chrono_tz::Tz` — this must never panic or block
471/// construction of a default `RuntimeConfig`.
472pub fn resolve_default_display_timezone() -> chrono_tz::Tz {
473    iana_time_zone::get_timezone()
474        .ok()
475        .and_then(|name| name.parse::<chrono_tz::Tz>().ok())
476        .unwrap_or(chrono_tz::Tz::UTC)
477}
478
479impl Default for RuntimeConfig {
480    fn default() -> Self {
481        let db_path = std::env::var("HOME")
482            .ok()
483            .map(|h| std::path::PathBuf::from(h).join(".khive/khive.db"));
484        let embedding_model = std::env::var("KHIVE_EMBEDDING_MODEL")
485            .ok()
486            .and_then(|s| s.parse().ok())
487            .or(Some(EmbeddingModel::AllMiniLmL6V2));
488        // Ships single-engine. A second engine is embedded on every write and
489        // searched on every read, which is cost a deployment should opt into
490        // rather than inherit: set KHIVE_ADDITIONAL_EMBEDDING_MODELS to add one.
491        let additional_embedding_models = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
492            .ok()
493            .map(|s| parse_embedding_model_list(&s))
494            .unwrap_or_default();
495        let packs = std::env::var("KHIVE_PACKS")
496            .ok()
497            .map(|s| parse_pack_list(&s))
498            .filter(|v| !v.is_empty())
499            .unwrap_or_else(Self::built_in_packs);
500        let brain_profile = std::env::var("KHIVE_BRAIN_PROFILE")
501            .ok()
502            .filter(|s| !s.trim().is_empty());
503        let actor_id = std::env::var("KHIVE_ACTOR")
504            .ok()
505            .filter(|s| !s.trim().is_empty());
506        Self {
507            db_path,
508            default_namespace: Namespace::local(),
509            embedding_model,
510            additional_embedding_models,
511            gate: Arc::new(AllowAllGate),
512            packs,
513            blob_hydration_bytes: crate::blob::DEFAULT_BLOB_HYDRATION_BYTES,
514            backend_id: BackendId::main(),
515            brain_profile,
516            visible_namespaces: vec![],
517            allowed_outbound_namespaces: vec![],
518            actor_id,
519            brain: crate::engine_config::BrainSectionConfig::default(),
520            git_write: crate::engine_config::GitWriteSectionConfig::default(),
521            exec: crate::engine_config::ExecSectionConfig::default(),
522            telemetry: crate::telemetry_config::TelemetryConfig::default(),
523            mounts: Vec::new(),
524            display_timezone: resolve_default_display_timezone(),
525            events_split: None,
526            web: crate::engine_config::WebSectionConfig::default(),
527        }
528    }
529}
530
531impl RuntimeConfig {
532    /// Return the shipped pack set used when no CLI, environment, or
533    /// configuration-file selection is present.
534    pub fn built_in_packs() -> Vec<String> {
535        [
536            "kg",
537            "gtd",
538            "memory",
539            "brain",
540            "comm",
541            "schedule",
542            "knowledge",
543            "session",
544            "tool",
545            "exec",
546            "git",
547            "code",
548            "workspace",
549            "blob",
550        ]
551        .into_iter()
552        .map(String::from)
553        .collect()
554    }
555
556    /// Build a `RuntimeConfig` with embedding disabled entirely.
557    ///
558    /// `embedding_model` and `additional_embedding_models` are computed
559    /// independently inside [`Default::default`], so `RuntimeConfig {
560    /// embedding_model: None, ..RuntimeConfig::default() }` does NOT produce a
561    /// model-less runtime: `additional_embedding_models` still carries its
562    /// env-driven fallback seed, and the note-write path fans out embedding to
563    /// every registered model regardless of `embedding_model`: so the first
564    /// `memory.remember` on a machine without local model files hard-fails
565    /// instead of degrading to FTS-only.
566    ///
567    /// This constructor clears both fields together and ignores
568    /// `KHIVE_ADDITIONAL_EMBEDDING_MODELS` unconditionally: the caller wants
569    /// zero embedders, not "zero unless the environment disagrees". Use it on
570    /// model-less machines (CI runners, fresh installs without local model
571    /// files) instead of the two-field struct-update form.
572    pub fn no_embeddings() -> Self {
573        Self {
574            embedding_model: None,
575            additional_embedding_models: Vec::new(),
576            ..Self::default()
577        }
578    }
579}
580
581/// Expand a leading `~` to `$HOME` in a path.
582///
583/// This is the single shared expansion point for every `RuntimeConfig.db_path`
584/// construction site (CLI `--db`, `KHIVE_DB`, declared `[[backends]].path`).
585/// [`resolve_db_anchor`] calls it so a `~`-prefixed override is expanded once,
586/// at resolution, before the path ever reaches boot (single- or multi-backend),
587/// `compute_config_id` fingerprinting, or the `--db` override equivalence
588/// guard — those consumers then agree on one expanded path instead of a raw
589/// `~` diverging from its already-expanded equivalent.
590pub fn expand_tilde(path: &std::path::Path) -> std::path::PathBuf {
591    let s = path.to_string_lossy();
592    if let Some(rest) = s.strip_prefix("~/") {
593        let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
594        std::path::PathBuf::from(format!("{home}/{rest}"))
595    } else if s == "~" {
596        let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
597        std::path::PathBuf::from(home)
598    } else {
599        path.to_path_buf()
600    }
601}
602
603/// Resolve the `--db`/`KHIVE_DB` value into the anchor path used for tier-3
604/// project-local `.khive/config.toml` discovery, mirroring the precedence
605/// `kkernel mcp` and `kkernel exec` use to open the database itself:
606/// `:memory:` has no file to anchor on (`None`); an explicit path anchors on
607/// that path (with a leading `~` expanded via [`expand_tilde`]); an unset
608/// value falls back to `$HOME/.khive/khive.db`, or `./.khive/khive.db` when
609/// `HOME` is unset.
610///
611/// Always resolves to a concrete anchor (unlike a 2-arm "override the
612/// default?" resolver): when `HOME` is unset this falls back to
613/// `./.khive/khive.db` rather than `None`, deliberately diverging from
614/// `RuntimeConfig::default()`: a caller anchoring config discovery needs a
615/// concrete directory to search even without `HOME`.
616pub fn resolve_db_anchor(db: Option<&str>) -> Option<std::path::PathBuf> {
617    match db {
618        Some(":memory:") => None,
619        Some(path) => Some(expand_tilde(std::path::Path::new(path))),
620        None => {
621            let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
622            Some(std::path::PathBuf::from(format!("{home}/.khive/khive.db")))
623        }
624    }
625}
626
627/// Assert that a resolved `db_path`: which `compute_config_id` folds into a
628/// process's `config_id`: agrees with what [`resolve_db_anchor`] derives from
629/// the same raw `--db`/`KHIVE_DB` input.
630///
631/// This compatibility entry point preserves the raw-string API. Construction
632/// paths that already captured the anchor should call
633/// [`assert_captured_db_anchor_consistent`] so they do not re-read mutable
634/// process environment.
635pub fn assert_db_anchor_consistent(
636    resolved_db_path: Option<&std::path::Path>,
637    args_db: Option<&str>,
638) -> anyhow::Result<()> {
639    let db_anchor = resolve_db_anchor(args_db);
640    assert_captured_db_anchor_consistent(resolved_db_path, db_anchor.as_deref())
641}
642
643/// Assert that a resolved `db_path`: which `compute_config_id` folds into a
644/// process's `config_id`: agrees with the database anchor captured from the
645/// same `--db`/`KHIVE_DB` input at the construction boundary.
646///
647/// Guards against a construction path recomputing `db_path` independently of
648/// `resolve_db_anchor`: left unchecked, that would silently desync `config_id`
649/// from a daemon or peer sharing the same database instead of failing loud.
650/// The caller passes the captured anchor so validation never re-reads mutable
651/// process environment. Inert (`Ok(())`) when the anchor itself is `None` (the
652/// `:memory:` sentinel) since there is nothing to compare against.
653pub fn assert_captured_db_anchor_consistent(
654    resolved_db_path: Option<&std::path::Path>,
655    db_anchor: Option<&std::path::Path>,
656) -> anyhow::Result<()> {
657    let Some(anchor) = db_anchor else {
658        return Ok(());
659    };
660    if resolved_db_path != Some(anchor) {
661        anyhow::bail!(
662            "db-path resolution drift at server construction: resolved db_path {:?} \
663             does not match the canonical anchor {:?} computed by resolve_db_anchor \
664             from the same --db input; this construction path likely recomputed the \
665             db path independently instead of routing through the shared resolver, \
666             which would desynchronize config_id from other processes sharing the \
667             same database",
668            resolved_db_path,
669            anchor
670        );
671    }
672    Ok(())
673}
674
675/// Resolve the per-connection attribution actor from the project/cwd-anchored
676/// config tier, independently of the database-anchored config load that
677/// governs `config_id`.
678///
679/// The database-anchored config load keeps `config_id` coherent between a
680/// short-lived client and a long-running daemon sharing one database, but
681/// when many per-project connections share one database under a single
682/// `HOME` (the daemon-multiplexed fleet case), that shared config carries no
683/// `[actor]` block, so every connection's write-stamp attribution collapses
684/// to the default identity.
685///
686/// This performs a separate, cwd-anchored lookup (`db_path: None`) and reads
687/// only `[actor].id`: it must not perturb `config_id` or `default_namespace`,
688/// which remain governed exclusively by the database-anchored load.
689///
690/// `config_path` is the same explicit `--config`/`KHIVE_CONFIG` override the
691/// caller's database-anchored load receives, so an explicit override wins
692/// here too.
693///
694/// Returns `Ok(None)` when no project-anchored config exists, or it exists
695/// but carries no non-empty `[actor].id`: callers fall through to their own
696/// env/anonymous tiers in that case. A `config_path` naming a file that does
697/// not exist is a loud error (`ExplicitConfigMissing`), matching the
698/// database-anchored load's explicit-tier contract (ADR-035) — in production
699/// that load errors first, so this lookup only ever sees an existing file.
700pub fn resolve_project_actor_id(
701    config_path: Option<&std::path::Path>,
702) -> Result<Option<String>, crate::engine_config::ConfigError> {
703    let khive_cfg = crate::engine_config::KhiveConfig::load_with_home_fallback(config_path, None)?;
704    Ok(khive_cfg
705        .and_then(|cfg| cfg.actor.id)
706        .filter(|s| !s.trim().is_empty()))
707}
708
709// ---- Embedding model helpers ----
710
711/// Sanitize an embedding model name into a valid SQL table suffix.
712/// e.g. `bge-small-en-v1.5` -> `bge_small_en_v1_5`
713pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
714    sanitize_key(&model.to_string())
715}
716
717pub(crate) fn sanitize_key(s: &str) -> String {
718    s.chars()
719        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
720        .collect()
721}
722
723pub(crate) fn build_embedder_registry(
724    config: &RuntimeConfig,
725) -> (crate::embedder_registry::EmbedderRegistry, Arc<str>) {
726    use crate::embedder_registry::{EmbedderRegistry, LatticeEmbedderProvider};
727    let mut registry = EmbedderRegistry::new();
728    for model in configured_embedding_models(config) {
729        registry.register(LatticeEmbedderProvider::new(model));
730    }
731    let default_embedder_name = config
732        .embedding_model
733        .map(|model| Arc::<str>::from(model.to_string()))
734        .unwrap_or_else(|| Arc::<str>::from(""));
735    (registry, default_embedder_name)
736}
737
738fn configured_embedding_models(config: &RuntimeConfig) -> Vec<EmbeddingModel> {
739    let mut models: Vec<EmbeddingModel> = Vec::new();
740    if let Some(model) = config.embedding_model {
741        models.push(model);
742    }
743    for model in config.additional_embedding_models.iter().copied() {
744        if !models.contains(&model) {
745            models.push(model);
746        }
747    }
748    models
749}
750
751pub(crate) fn register_configured_embedding_models(
752    backend: &StorageBackend,
753    config: &RuntimeConfig,
754) -> RuntimeResult<()> {
755    for model in configured_embedding_models(config) {
756        backend.register_embedding_model(
757            &model.to_string(),
758            model.model_id(),
759            model.key_version(),
760            model.dimensions() as u32,
761        )?;
762    }
763    Ok(())
764}
765
766/// Build a `RuntimeConfig` from a parsed `KhiveConfig`.
767///
768/// For each `[[engines]]` entry:
769/// - The engine flagged `default = true` becomes `RuntimeConfig::embedding_model`.
770/// - All other engines become `RuntimeConfig::additional_embedding_models`.
771///
772/// `KhiveConfig::validate()` rejects an unrecognized engine model at load time.
773/// A caller-constructed config that bypasses validation still skips an invalid
774/// engine with a warning here.
775///
776/// If `khive_cfg.engines` is empty, the returned `RuntimeConfig` uses the
777/// env-var-derived defaults from `RuntimeConfig::default()`.
778///
779/// When both a config file and `KHIVE_EMBEDDING_MODEL` env var are present,
780/// the caller is responsible for emitting a warning that env vars are overridden.
781/// This function purely converts `KhiveConfig` to `RuntimeConfig` fields.
782pub fn runtime_config_from_khive_config(
783    khive_cfg: &crate::engine_config::KhiveConfig,
784    base: RuntimeConfig,
785) -> RuntimeConfig {
786    // `[actor] id` never becomes the storage namespace (writes always pin to
787    // `local`); it only widens the read visible-set below.
788    let default_namespace = base.default_namespace.clone();
789    let mounts = khive_cfg.mounts.clone();
790
791    // base.brain_profile must carry only the explicit CLI tier, never an env
792    // value: env sits below toml in precedence and is applied later by the MCP resolver.
793    let brain_profile = base.brain_profile.clone().or_else(|| {
794        khive_cfg
795            .runtime
796            .brain_profile
797            .clone()
798            .filter(|s| !s.trim().is_empty())
799    });
800
801    let visible_namespaces: Vec<Namespace> = khive_cfg
802        .actor
803        .visible_namespaces
804        .as_deref()
805        .unwrap_or_default()
806        .iter()
807        .filter_map(|s| match Namespace::parse(s) {
808            Ok(ns) => Some(ns),
809            Err(e) => {
810                tracing::warn!(ns = %s, error = %e, "actor.visible_namespaces: invalid namespace; skipped");
811                None
812            }
813        })
814        .collect();
815
816    // Fold actor.id's namespace into visible_namespaces so default reads widen
817    // to {local} ∪ {actor namespace}; skipped when it parses to `local` (would
818    // duplicate the primary namespace already minted) or is already present.
819    let visible_namespaces = if let Some(id) = khive_cfg.actor.id.as_deref() {
820        match Namespace::parse(id) {
821            Ok(actor_ns) if actor_ns != Namespace::local() => {
822                let mut v = visible_namespaces;
823                if !v.contains(&actor_ns) {
824                    v.push(actor_ns);
825                }
826                v
827            }
828            _ => visible_namespaces,
829        }
830    } else {
831        visible_namespaces
832    };
833
834    // KhiveConfig::validate() guarantees these are valid Namespace strings, so
835    // parse failures here are unreachable for validated configs; filter_map+warn
836    // guards against a validation bug panicking instead.
837    let allowed_outbound_namespaces: Vec<Namespace> = khive_cfg
838        .actor
839        .allowed_outbound_namespaces
840        .iter()
841        .filter_map(|s| match Namespace::parse(s) {
842            Ok(ns) => Some(ns),
843            Err(e) => {
844                tracing::warn!(ns = %s, error = %e, "actor.allowed_outbound_namespaces: invalid namespace; skipped");
845                None
846            }
847        })
848        .collect();
849
850    // Precedence: TOML `[actor] id` > `base.actor_id` (env/CLI-resolved) >
851    // anonymous. Falls back to `base.actor_id` rather than `None` when
852    // `[actor] id` is absent: otherwise an env-resolved actor like
853    // `KHIVE_ACTOR` is silently dropped whenever a project config exists
854    // without an `[actor]` block.
855    let actor_id = khive_cfg
856        .actor
857        .id
858        .clone()
859        .filter(|s| !s.trim().is_empty())
860        .or_else(|| base.actor_id.clone());
861
862    let gate = khive_cfg
863        .gate
864        .as_ref()
865        .map(|gate| {
866            Arc::new(khive_gate::CallerEnrollmentGate::with_write_denials(
867                gate.granted_actors.clone(),
868                gate.grant_unattributed,
869                gate.deny_writes_for.clone(),
870            )) as GateRef
871        })
872        .unwrap_or_else(|| base.gate.clone());
873    let gate = crate::mailbox_view::configured_mailbox_gate(&khive_cfg.actor, gate);
874
875    let brain = khive_cfg.brain.clone();
876    let git_write = khive_cfg.git_write.clone();
877    let exec = khive_cfg.exec.clone();
878    let telemetry = khive_cfg.telemetry.clone();
879    let web = khive_cfg.web.clone();
880    let blob_hydration_bytes = khive_cfg
881        .runtime
882        .blob_hydration_bytes
883        .unwrap_or(base.blob_hydration_bytes);
884
885    // KhiveConfig::validate() guarantees a present timezone parses as a valid
886    // chrono_tz::Tz, so the fallback to base.display_timezone below is only
887    // reachable for an unvalidated caller-constructed KhiveConfig, not a
888    // config loaded via KhiveConfig::load.
889    let display_timezone = khive_cfg
890        .display
891        .timezone
892        .as_deref()
893        .and_then(|s| s.parse::<chrono_tz::Tz>().ok())
894        .unwrap_or(base.display_timezone);
895
896    if khive_cfg.engines.is_empty() {
897        return RuntimeConfig {
898            default_namespace,
899            brain_profile,
900            visible_namespaces,
901            allowed_outbound_namespaces,
902            actor_id,
903            gate,
904            brain,
905            git_write,
906            exec,
907            telemetry,
908            mounts,
909            blob_hydration_bytes,
910            display_timezone,
911            web,
912            ..base
913        };
914    }
915
916    let mut embedding_model: Option<EmbeddingModel> = None;
917    let mut additional: Vec<EmbeddingModel> = Vec::new();
918
919    for engine in &khive_cfg.engines {
920        match parse_embedding_model_alias(&engine.model) {
921            Some(model) => {
922                if engine.default {
923                    embedding_model = Some(model);
924                } else {
925                    additional.push(model);
926                }
927            }
928            None => {
929                tracing::warn!(
930                    engine = %engine.name,
931                    model = %engine.model,
932                    "engine config: unknown model name; engine will be skipped"
933                );
934            }
935        }
936    }
937
938    RuntimeConfig {
939        embedding_model,
940        additional_embedding_models: additional,
941        default_namespace,
942        brain_profile,
943        visible_namespaces,
944        allowed_outbound_namespaces,
945        actor_id,
946        gate,
947        brain,
948        git_write,
949        exec,
950        telemetry,
951        mounts,
952        blob_hydration_bytes,
953        display_timezone,
954        web,
955        ..base
956    }
957}
958
959#[cfg(test)]
960mod display_timezone_tests {
961    use super::resolve_default_display_timezone;
962
963    // The host's actual zone is environment-dependent (CI runners are
964    // typically UTC), so this only asserts the resolver always produces some
965    // valid, non-panicking Tz — never that it matches a specific zone.
966    #[test]
967    fn resolve_default_display_timezone_never_panics() {
968        let _tz = resolve_default_display_timezone();
969    }
970}
971
972/// Parse a comma- or whitespace-separated list of embedding model names.
973fn parse_embedding_model_list(s: &str) -> Vec<EmbeddingModel> {
974    parse_pack_list(s)
975        .into_iter()
976        .filter_map(|raw| {
977            let parsed = parse_embedding_model_alias(&raw);
978            if parsed.is_none() && !raw.trim().is_empty() {
979                tracing::warn!(
980                    model = %raw,
981                    "KHIVE_ADDITIONAL_EMBEDDING_MODELS contains unknown model name; ignored. \
982                     Valid forms: short alias like 'paraphrase' or a fully-qualified key \
983                     from lattice_embed::EmbeddingModel::from_str."
984                );
985            }
986            parsed
987        })
988        .collect()
989}
990
991pub(crate) fn parse_embedding_model_alias(name: &str) -> Option<EmbeddingModel> {
992    let normalized = name.trim().to_ascii_lowercase().replace('_', "-");
993    match normalized.as_str() {
994        "paraphrase" => Some(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2),
995        _ => normalized.parse().ok(),
996    }
997}
998
999#[cfg(test)]
1000mod resolve_db_anchor_tests {
1001    use super::resolve_db_anchor;
1002
1003    #[test]
1004    fn memory_sentinel_maps_to_none() {
1005        assert_eq!(resolve_db_anchor(Some(":memory:")), None);
1006    }
1007
1008    #[test]
1009    fn explicit_path_maps_to_some() {
1010        assert_eq!(
1011            resolve_db_anchor(Some("/tmp/khive-anchor-test.db")),
1012            Some(std::path::PathBuf::from("/tmp/khive-anchor-test.db"))
1013        );
1014    }
1015
1016    #[test]
1017    fn absent_maps_to_home_default() {
1018        let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
1019        let expected = std::path::PathBuf::from(format!("{home}/.khive/khive.db"));
1020        assert_eq!(resolve_db_anchor(None), Some(expected));
1021    }
1022}
1023
1024#[cfg(test)]
1025mod assert_db_anchor_consistent_tests {
1026    use super::{assert_captured_db_anchor_consistent, resolve_db_anchor};
1027    use crate::assert_db_anchor_consistent;
1028
1029    #[test]
1030    fn diverging_db_path_is_rejected_naming_both_paths() {
1031        let args_db = "/tmp/khive-anchor-guard-real.db";
1032        let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
1033        let wrong = std::path::PathBuf::from("/tmp/khive-anchor-guard-wrong.db");
1034
1035        let err =
1036            assert_captured_db_anchor_consistent(Some(wrong.as_path()), Some(anchor.as_path()))
1037                .expect_err("a resolved db_path diverging from the anchor must be rejected");
1038
1039        let msg = err.to_string();
1040        assert!(
1041            msg.contains(&wrong.display().to_string()),
1042            "error must name the resolved (wrong) path: {msg}"
1043        );
1044        assert!(
1045            msg.contains(&anchor.display().to_string()),
1046            "error must name the canonical anchor path: {msg}"
1047        );
1048    }
1049
1050    #[test]
1051    fn matching_explicit_db_path_passes() {
1052        let args_db = "/tmp/khive-anchor-guard-consistent.db";
1053        let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
1054        assert!(assert_captured_db_anchor_consistent(
1055            Some(anchor.as_path()),
1056            Some(anchor.as_path())
1057        )
1058        .is_ok());
1059    }
1060
1061    #[test]
1062    fn memory_sentinel_anchor_is_inert() {
1063        // `resolve_db_anchor(":memory:")` yields `None` — there is no canonical
1064        // path to assert against, so the guard passes regardless of what
1065        // `resolved_db_path` happens to carry.
1066        let bogus = std::path::PathBuf::from("/tmp/should-not-matter.db");
1067        assert!(assert_captured_db_anchor_consistent(Some(bogus.as_path()), None).is_ok());
1068        assert!(assert_captured_db_anchor_consistent(None, None).is_ok());
1069    }
1070
1071    #[test]
1072    fn normal_boot_with_db_unset_passes_silently() {
1073        // Mirrors a normal boot with `--db` unset: `resolve_db_anchor(None)`
1074        // always resolves to `Some(..)` (HOME-set or -unset both produce a
1075        // concrete anchor), so a runtime whose resolved `db_path` matches
1076        // passes silently.
1077        let anchor = resolve_db_anchor(None);
1078        assert!(assert_captured_db_anchor_consistent(anchor.as_deref(), anchor.as_deref()).is_ok());
1079    }
1080
1081    #[test]
1082    fn public_compatibility_wrapper_accepts_path_and_memory_sentinel() {
1083        let args_db = "/tmp/khive-anchor-guard-public-api.db";
1084        let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
1085        assert!(assert_db_anchor_consistent(Some(anchor.as_path()), Some(args_db)).is_ok());
1086
1087        let unrelated = std::path::Path::new("/tmp/khive-anchor-guard-unrelated.db");
1088        assert!(assert_db_anchor_consistent(Some(unrelated), Some(":memory:")).is_ok());
1089    }
1090}
1091
1092#[cfg(test)]
1093mod resolve_project_actor_id_tests {
1094    use super::resolve_project_actor_id;
1095
1096    fn write_toml(dir: &tempfile::TempDir, body: &str) -> std::path::PathBuf {
1097        let path = dir.path().join("config.toml");
1098        std::fs::write(&path, body).expect("write config.toml");
1099        path
1100    }
1101
1102    #[test]
1103    fn extracts_non_empty_actor_id_from_explicit_path() {
1104        let dir = tempfile::tempdir().expect("tempdir");
1105        let path = write_toml(&dir, "[actor]\nid = \"lambda:explicit-actor\"\n");
1106
1107        assert_eq!(
1108            resolve_project_actor_id(Some(&path)).expect("no error"),
1109            Some("lambda:explicit-actor".to_string())
1110        );
1111    }
1112
1113    #[test]
1114    fn missing_explicit_path_fails_loud() {
1115        // The explicit tier is enforced inside the loader
1116        // (`KhiveConfig::load_with_home_fallback_and_source` returns
1117        // `ExplicitConfigMissing`): a nonexistent explicit path is an error,
1118        // never a silent `None` (ADR-035).
1119        let missing = std::path::PathBuf::from("/nonexistent/khive-project-actor-test/config.toml");
1120        let err = resolve_project_actor_id(Some(&missing))
1121            .expect_err("a missing explicit path must fail loud");
1122        assert!(
1123            matches!(
1124                err,
1125                crate::engine_config::ConfigError::ExplicitConfigMissing { .. }
1126            ),
1127            "expected ExplicitConfigMissing, got {err:?}"
1128        );
1129    }
1130
1131    #[test]
1132    fn propagates_load_error_for_invalid_actor_id() {
1133        // `KhiveConfig::load`'s `validate()` rejects an empty `[actor] id` before
1134        // the emptiness filter in `resolve_project_actor_id` ever sees it; this
1135        // asserts the error surfaces rather than being swallowed into `Ok(None)`.
1136        let dir = tempfile::tempdir().expect("tempdir");
1137        let path = write_toml(&dir, "[actor]\nid = \"\"\n");
1138
1139        let err = resolve_project_actor_id(Some(&path)).expect_err("invalid actor.id must error");
1140        let root = match &err {
1141            crate::engine_config::ConfigError::InFile { source, .. } => source.as_ref(),
1142            other => other,
1143        };
1144        assert!(
1145            matches!(
1146                root,
1147                crate::engine_config::ConfigError::InvalidActorId { .. }
1148            ),
1149            "expected InvalidActorId, got {err:?}"
1150        );
1151    }
1152
1153    #[test]
1154    fn returns_none_when_config_has_no_actor_section() {
1155        let dir = tempfile::tempdir().expect("tempdir");
1156        let path = write_toml(
1157            &dir,
1158            "[[engines]]\nname = \"primary\"\nmodel = \"bge-small-en-v1.5\"\ndefault = true\n",
1159        );
1160
1161        assert_eq!(
1162            resolve_project_actor_id(Some(&path)).expect("no error"),
1163            None,
1164            "a config file with no [actor] section must resolve to None"
1165        );
1166    }
1167}
1168
1169#[cfg(test)]
1170mod no_embeddings_tests {
1171    use super::*;
1172    use serial_test::serial;
1173
1174    #[test]
1175    fn no_embeddings_clears_both_fields() {
1176        let config = RuntimeConfig::no_embeddings();
1177        assert_eq!(config.embedding_model, None);
1178        assert!(config.additional_embedding_models.is_empty());
1179        assert!(
1180            configured_embedding_models(&config).is_empty(),
1181            "no_embeddings() must yield zero configured embedders"
1182        );
1183    }
1184
1185    #[test]
1186    fn blob_hydration_default_is_four_portable_whole_objects() {
1187        assert_eq!(
1188            RuntimeConfig::default().blob_hydration_bytes,
1189            4 * khive_storage::MAX_BLOB_WHOLE_BYTES
1190        );
1191    }
1192
1193    #[test]
1194    #[serial]
1195    fn no_embeddings_ignores_additional_env_override() {
1196        // no_embeddings() is an unconditional opt-out: even if the caller's
1197        // environment sets KHIVE_ADDITIONAL_EMBEDDING_MODELS, the resulting
1198        // config must still report zero embedders.
1199        std::env::set_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS", "paraphrase");
1200        let config = RuntimeConfig::no_embeddings();
1201        std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
1202
1203        assert!(config.additional_embedding_models.is_empty());
1204        assert!(configured_embedding_models(&config).is_empty());
1205    }
1206
1207    #[test]
1208    #[serial]
1209    fn default_computes_additional_models_independently_of_no_embeddings() {
1210        // `Default` must keep computing `embedding_model` and
1211        // `additional_embedding_models` independently; `no_embeddings()` is a
1212        // separate opt-out constructor, not a change to `Default`'s seeding.
1213        // The env var is SET here rather than cleared: since Default now ships
1214        // no secondary engine, an unset env would make Default and
1215        // no_embeddings() indistinguishable and the test would stop
1216        // discriminating the thing it exists to discriminate.
1217        std::env::set_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS", "paraphrase");
1218        let config = RuntimeConfig::default();
1219        let buggy_form = RuntimeConfig {
1220            embedding_model: None,
1221            ..RuntimeConfig::default()
1222        };
1223        std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
1224
1225        assert_eq!(
1226            config.additional_embedding_models,
1227            vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]
1228        );
1229
1230        // Overriding only `embedding_model` via struct-update syntax does not
1231        // clear `additional_embedding_models`.
1232        assert!(
1233            !buggy_form.additional_embedding_models.is_empty(),
1234            "Default's independent-field seeding must remain unchanged; \
1235             no_embeddings() is the fix, not a change to Default"
1236        );
1237    }
1238
1239    #[test]
1240    #[serial]
1241    fn default_ships_a_single_engine_when_env_unset() {
1242        // A secondary engine is embedded on every write and searched on every
1243        // read. That cost is opted into, not inherited.
1244        std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
1245        let config = RuntimeConfig::default();
1246
1247        assert!(
1248            config.additional_embedding_models.is_empty(),
1249            "shipped default must register one engine; a second is opt-in via \
1250             KHIVE_ADDITIONAL_EMBEDDING_MODELS"
1251        );
1252        assert_eq!(
1253            configured_embedding_models(&config),
1254            vec![EmbeddingModel::AllMiniLmL6V2]
1255        );
1256    }
1257}
1258
1259#[cfg(test)]
1260mod ann_fresh_tail_config_tests {
1261    use super::ann_fresh_tail_enabled_from_value;
1262
1263    #[test]
1264    fn only_exact_zero_disables_fresh_tail() {
1265        assert!(ann_fresh_tail_enabled_from_value(None));
1266        assert!(!ann_fresh_tail_enabled_from_value(Some("0")));
1267        assert!(ann_fresh_tail_enabled_from_value(Some("1")));
1268        assert!(ann_fresh_tail_enabled_from_value(Some("false")));
1269        assert!(ann_fresh_tail_enabled_from_value(Some(" 0")));
1270    }
1271}
1272
1273#[cfg(test)]
1274mod configured_embedding_models_order_tests {
1275    use super::*;
1276
1277    /// Issue #1115: the configured engine list must preserve declaration
1278    /// order (primary first, then `additional_embedding_models` in order)
1279    /// instead of alphabetizing — any consumer that treats the list as
1280    /// ordered (e.g. recall fan-out) otherwise gets the wrong primary.
1281    #[test]
1282    fn preserves_primary_first_then_additional_in_declared_order() {
1283        let config = RuntimeConfig {
1284            embedding_model: Some(EmbeddingModel::AllMiniLmL6V2),
1285            additional_embedding_models: vec![
1286                EmbeddingModel::Qwen3Embedding4B,
1287                EmbeddingModel::BgeSmallEnV15,
1288            ],
1289            ..RuntimeConfig::default()
1290        };
1291
1292        assert_eq!(
1293            configured_embedding_models(&config),
1294            vec![
1295                EmbeddingModel::AllMiniLmL6V2,
1296                EmbeddingModel::Qwen3Embedding4B,
1297                EmbeddingModel::BgeSmallEnV15,
1298            ],
1299            "order must be primary-first, then additional models as declared, \
1300             not alphabetized"
1301        );
1302    }
1303
1304    /// A model repeated in both `embedding_model` and `additional_embedding_models`
1305    /// must be deduped to a single entry, keeping its first (primary) position.
1306    #[test]
1307    fn dedupes_model_shared_between_primary_and_additional() {
1308        let config = RuntimeConfig {
1309            embedding_model: Some(EmbeddingModel::AllMiniLmL6V2),
1310            additional_embedding_models: vec![
1311                EmbeddingModel::AllMiniLmL6V2,
1312                EmbeddingModel::BgeSmallEnV15,
1313            ],
1314            ..RuntimeConfig::default()
1315        };
1316
1317        assert_eq!(
1318            configured_embedding_models(&config),
1319            vec![EmbeddingModel::AllMiniLmL6V2, EmbeddingModel::BgeSmallEnV15],
1320            "the shared model must appear once, in its primary position"
1321        );
1322    }
1323}