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