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 /// The WAL ceiling applied to this runtime's implicit main backend.
333 /// Zero means disabled, including for a read-only backend that retains a
334 /// nonzero configured value only for operator reporting.
335 pub wal_ceiling_bytes: u64,
336 /// The configured value before read-only writer-policy suppression.
337 pub wal_ceiling_configured_bytes: u64,
338 /// Where the configured ceiling came from.
339 pub wal_ceiling_source: WalCeilingSource,
340 /// Construction-time snapshot of the environment fallback. The host uses
341 /// this same value to resolve every named backend before forwarding or
342 /// opening it, without rereading mutable process environment.
343 pub wal_ceiling_env_raw: Option<String>,
344 /// One snapshot for named backend resolution and daemon compatibility.
345 pub disk_guard_environment: khive_db::DiskGuardEnvironment,
346 /// Captured implicit-main policy, or an explicit caller-supplied policy.
347 pub disk_guard_config: Option<khive_db::EffectiveDiskGuardConfig>,
348 /// Directory for the SQLite volume lock files, captured with the rest of
349 /// boot configuration from [`crate::daemon::volume_lock_dir`]. `None` means
350 /// no directory could be resolved; every writable file-backed open then
351 /// fails with a configuration error naming `KHIVE_VOLUME_LOCK_DIR`.
352 pub volume_lock_dir: Option<std::path::PathBuf>,
353 /// Namespace used when no explicit namespace is provided.
354 pub default_namespace: Namespace,
355 /// Ordered participating engines. `Some`, including an empty list, is
356 /// authoritative; `None` converts the legacy model fields at startup.
357 pub engines: Option<Vec<crate::engine_config::EngineConfig>>,
358 /// Legacy local embedding model, used when `engines` is absent and otherwise
359 /// projected from its first peer at startup. `None` alone does not disable embedding: setting
360 /// only this field to `None` while `additional_embedding_models` is
361 /// non-empty still registers those models. Both `embedding_model` and
362 /// `additional_embedding_models` must be empty to disable built-in
363 /// embedding model registration, at which point `hybrid_search` falls back
364 /// to text-only. Use [`RuntimeConfig::no_embeddings`] to clear both fields
365 /// together — it is the canonical constructor for this. Custom embedder
366 /// providers registered later by packs are not affected by this field.
367 ///
368 /// Deprecated: embedding engines move to a per-pack `EmbedderRegistry`.
369 /// This field persists for backward compatibility until the embedder registry
370 /// is fully plumbed.
371 pub embedding_model: Option<EmbeddingModel>,
372 /// Legacy additional embedding models, used when `engines` is absent and
373 /// otherwise projected from its remaining peers at startup.
374 ///
375 /// `embedding_model` remains the default used by existing `embed()` and
376 /// `embed_batch()` callers. This list adds non-default models that can be
377 /// selected with `embedder(name)`, `embed_with_model(...)`, memory
378 /// `remember.embedding_model`, and memory `recall.embedding_model`.
379 pub additional_embedding_models: Vec<EmbeddingModel>,
380 /// Authorization gate consulted before each verb dispatch.
381 /// Default: `AllowAllGate` (permissive). For production policy enforcement,
382 /// plug in a Rego- or capability-witness-backed impl.
383 pub gate: GateRef,
384 /// Names of packs the transport layer should register into the VerbRegistry.
385 /// The transport layer (e.g. `khive-mcp`) reads this list and instantiates
386 /// the matching concrete pack types. Unknown names are reported as errors
387 /// by the transport, not silently ignored.
388 /// Defaults to the shipped production set returned by
389 /// [`RuntimeConfig::built_in_packs`].
390 pub packs: Vec<String>,
391 /// Resolved aggregate admission budget for resident digest-verified blob
392 /// buffers (ADR-160 D3), in raw bytes.
393 ///
394 /// `[runtime].blob_hydration_bytes` may raise or lower the built-in 256 MiB
395 /// value, but config validation guarantees enough room for one maximum-size
396 /// whole-blob read.
397 pub blob_hydration_bytes: u64,
398 /// Identifies this runtime's backend in a multi-backend deployment.
399 ///
400 /// Set by the boot path when constructing per-pack runtimes from `khive.toml`.
401 /// Single-backend deployments use the default `BackendId::MAIN`.
402 pub backend_id: BackendId,
403 /// Brain profile to use for `memory.feedback` / `knowledge.feedback` and
404 /// recall-time score boosting (ADR-035 §Brain profile configuration).
405 ///
406 /// Resolution order (highest to lowest, ADR-035): CLI flag, then
407 /// `runtime.brain_profile` in project/global `khive.toml`, then the
408 /// `KHIVE_BRAIN_PROFILE` env var as fallback default. Callers must keep
409 /// env OUT of the base config they pass in (see `khive-mcp` serve.rs).
410 /// 1. `--brain-profile` CLI flag (explicit only)
411 /// 2. Namespace-bound profile resolved via `brain.resolve` at feedback time
412 /// 3. Pack-local global tuning prior (default fallback)
413 pub brain_profile: Option<String>,
414 /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
415 ///
416 /// OSS dispatch widens the DEFAULT multi-record read scope to
417 /// `['local'] ∪ visible_namespaces`. Writes remain pinned to `'local'`.
418 /// An explicit `namespace=` request param is a precise single-namespace
419 /// escape and is not widened. Populated from `actor.visible_namespaces`
420 /// in `khive.toml`.
421 pub visible_namespaces: Vec<Namespace>,
422 /// Namespaces this actor's comm.send/reply may deliver messages INTO
423 /// (outbound, sender-side). Populated from `actor.allowed_outbound_namespaces`
424 /// in `khive.toml`. Empty by default — cross-namespace delivery denied
425 /// unless explicitly declared. The comm handler uses an ordinary
426 /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
427 /// the token itself is NOT type-enforced write-only. The recipient-side
428 /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
429 /// a future cloud-path authorization ADR (not yet written).
430 pub allowed_outbound_namespaces: Vec<Namespace>,
431 /// Configured actor identity label (ADR-057). Populated from `[actor] id` in
432 /// `khive.toml`. When `Some`, `authorize()` mints tokens carrying this actor
433 /// label so that `comm.inbox` filters by `to_actor` instead of falling back to
434 /// the party-line "local" behavior. When `None` (default), tokens carry
435 /// `ActorRef::anonymous()` and inbox is scoped to party-line messages —
436 /// those addressed to `"local"` or carrying no `to_actor` stamp.
437 pub actor_id: Option<String>,
438 /// Resolved `[brain]` policy from the serving process's configuration.
439 pub brain: crate::engine_config::BrainSectionConfig,
440 /// Resolved `[git_write]` policy allowlist (ADR-108 Amendment), populated
441 /// from `khive.toml`'s `[[git_write.allowed]]` entries by
442 /// [`runtime_config_from_khive_config`]. Threaded through so
443 /// `khive-pack-git`'s write-verb handlers read an already-resolved policy
444 /// instead of re-running config discovery (which would ignore an
445 /// explicit `--config` path not also exported as `KHIVE_CONFIG`).
446 pub git_write: crate::engine_config::GitWriteSectionConfig,
447 /// Effective `[blob]` file-transfer opt-in, resolved with the exact environment
448 /// opt-in during construction and retained by the blob pack.
449 pub blob: crate::engine_config::BlobSectionConfig,
450 /// Resolved `[exec]` sandbox section (ADR-181), threaded through like
451 /// `git_write` so the exec pack reads an already-resolved config.
452 pub exec: crate::engine_config::ExecSectionConfig,
453 /// Resolved carrier and failure policy for telemetry emission.
454 pub telemetry: crate::telemetry_config::TelemetryConfig,
455 /// Resolved rendering timezone (ADR-169), consumed today by date-only
456 /// `parse_due` anchoring. Populated from `[display] timezone` in
457 /// `khive.toml` by [`runtime_config_from_khive_config`]; when absent,
458 /// resolves once to the host's local IANA zone (falling back to UTC when
459 /// the host zone cannot be determined) via [`resolve_default_display_timezone`].
460 pub display_timezone: chrono_tz::Tz,
461 /// Events-daemon split (ADR-170). `None` = legacy behavior: events persist
462 /// in the main store. `Some` routes event persistence to the events database —
463 /// forwarded over the events daemon socket in daemon deployments, opened
464 /// directly in embedded/one-shot contexts. Populated by the transport
465 /// hosts (khive-mcp serve, kkernel exec); tests and in-memory runtimes
466 /// leave it `None`.
467 pub events_split: Option<crate::events_split::EventsSplitConfig>,
468 /// Resolved `[web]` policy (ADR-175 Amendment 1), threaded through like
469 /// `exec`/`git_write` so `khive-pack-web` reads an already-resolved config.
470 pub web: crate::engine_config::WebSectionConfig,
471}
472
473/// Parse a comma- or whitespace-separated pack list from a single string.
474///
475/// Empty entries are dropped, surrounding whitespace is trimmed.
476pub fn parse_pack_list(s: &str) -> Vec<String> {
477 s.split(|c: char| c == ',' || c.is_whitespace())
478 .map(str::trim)
479 .filter(|s| !s.is_empty())
480 .map(str::to_owned)
481 .collect()
482}
483
484const ANN_REBUILD_THRESHOLD_DEFAULT: f64 = 0.20;
485
486/// Read the ANN tail-rebuild fraction on each invocation (ADR-079 Amendment 2).
487///
488/// ADR-079's `ann_rebuild_threshold` default rationale describes the raw-row
489/// policy, its workload assumptions and its coupled maintenance/visibility roles.
490///
491/// The exact environment value must parse as `f64` and lie in `(0, 1]`;
492/// unset, non-Unicode, malformed and out-of-range values use `0.20`.
493/// This reader deliberately does not trim or cache the value.
494pub fn ann_rebuild_threshold_from_env() -> f64 {
495 let value = khive_types::env::env_parse_or(
496 "KHIVE_ANN_REBUILD_THRESHOLD",
497 ANN_REBUILD_THRESHOLD_DEFAULT,
498 );
499 if value > 0.0 && value <= 1.0 {
500 value
501 } else {
502 ANN_REBUILD_THRESHOLD_DEFAULT
503 }
504}
505
506#[cfg(test)]
507#[path = "config/ann_rebuild_threshold_tests.rs"]
508mod ann_rebuild_threshold_tests;
509
510/// Interpret the construction-time `KHIVE_ANN_FRESH_TAIL` value.
511///
512/// Preserve the escape hatch's existing semantics while moving its read out of
513/// request serving: only `0` disables; unset and every other value enable.
514fn ann_fresh_tail_enabled_from_value(value: Option<&str>) -> bool {
515 value != Some("0")
516}
517
518/// Sample ADR-118's fresh-tail escape hatch for runtime construction.
519///
520/// Callers must retain the returned value for the runtime's lifetime instead
521/// of re-reading the environment on a serving path. Only the exact value `0`
522/// disables; unset and every other value enable.
523pub fn ann_fresh_tail_enabled_from_env() -> bool {
524 let value = std::env::var("KHIVE_ANN_FRESH_TAIL").ok();
525 ann_fresh_tail_enabled_from_value(value.as_deref())
526}
527
528/// Resolve the host's local IANA zone name once, for [`RuntimeConfig`]'s
529/// default `display_timezone` (ADR-169). Falls back to UTC when the host
530/// zone cannot be determined (e.g. `TZ`/`/etc/localtime` unreadable) or does
531/// not parse as a known `chrono_tz::Tz` — this must never panic or block
532/// construction of a default `RuntimeConfig`.
533pub fn resolve_default_display_timezone() -> chrono_tz::Tz {
534 iana_time_zone::get_timezone()
535 .ok()
536 .and_then(|name| name.parse::<chrono_tz::Tz>().ok())
537 .unwrap_or(chrono_tz::Tz::UTC)
538}
539
540impl Default for RuntimeConfig {
541 fn default() -> Self {
542 let db_path = std::env::var("HOME")
543 .ok()
544 .map(|h| std::path::PathBuf::from(h).join(".khive/khive.db"));
545 let embedding_model = Some(khive_types::env::env_parse_or(
546 "KHIVE_EMBEDDING_MODEL",
547 EmbeddingModel::AllMiniLmL6V2,
548 ));
549 // Ships single-engine. A second engine is embedded on every write and
550 // searched on every read, which is cost a deployment should opt into
551 // rather than inherit: set KHIVE_ADDITIONAL_EMBEDDING_MODELS to add one.
552 let additional_embedding_models = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
553 .ok()
554 .map(|s| parse_embedding_model_list(&s))
555 .unwrap_or_default();
556 let packs = std::env::var("KHIVE_PACKS")
557 .ok()
558 .map(|s| parse_pack_list(&s))
559 .filter(|v| !v.is_empty())
560 .unwrap_or_else(Self::built_in_packs);
561 let brain_profile = std::env::var("KHIVE_BRAIN_PROFILE")
562 .ok()
563 .filter(|s| !s.trim().is_empty());
564 let actor_id = std::env::var("KHIVE_ACTOR")
565 .ok()
566 .filter(|s| !s.trim().is_empty());
567 Self {
568 credentials: Vec::new(),
569 visibility_receipts: None,
570 db_path,
571 wal_ceiling_bytes: 0,
572 wal_ceiling_configured_bytes: 0,
573 wal_ceiling_source: WalCeilingSource::Default,
574 wal_ceiling_env_raw: std::env::var_os("KHIVE_SQLITE_WAL_CEILING_BYTES")
575 .map(|value| value.to_string_lossy().into_owned()),
576 disk_guard_environment: khive_db::DiskGuardEnvironment::capture(),
577 disk_guard_config: None,
578 volume_lock_dir: crate::daemon::volume_lock_dir().ok(),
579 default_namespace: Namespace::local(),
580 engines: None,
581 embedding_model,
582 additional_embedding_models,
583 gate: Arc::new(AllowAllGate),
584 packs,
585 blob_hydration_bytes: crate::blob::DEFAULT_BLOB_HYDRATION_BYTES,
586 backend_id: BackendId::main(),
587 brain_profile,
588 visible_namespaces: vec![],
589 allowed_outbound_namespaces: vec![],
590 actor_id,
591 brain: crate::engine_config::BrainSectionConfig::default(),
592 git_write: crate::engine_config::GitWriteSectionConfig::default(),
593 blob: crate::engine_config::BlobSectionConfig {
594 file_transfers: std::env::var("KHIVE_FILE_TRANSFERS")
595 .map(|value| value == "1")
596 .unwrap_or(false),
597 },
598 exec: crate::engine_config::ExecSectionConfig::default(),
599 telemetry: crate::telemetry_config::TelemetryConfig::default(),
600 mounts: Vec::new(),
601 display_timezone: resolve_default_display_timezone(),
602 events_split: None,
603 web: crate::engine_config::WebSectionConfig::default(),
604 }
605 }
606}
607
608impl RuntimeConfig {
609 /// Effective ordered peers with canonical built-in names, without binding
610 /// providers or mutating the configuration. Startup must still validate them.
611 pub fn configured_engines(&self) -> Vec<crate::engine_config::EngineConfig> {
612 match &self.engines {
613 Some(engines) => engines
614 .iter()
615 .cloned()
616 .map(|mut engine| {
617 engine.name = crate::engine_config::canonical_engine_name(&engine.name);
618 engine
619 })
620 .collect(),
621 None => configured_embedding_models(self)
622 .into_iter()
623 .map(|model| crate::engine_config::EngineConfig {
624 name: model.to_string(),
625 weight: 1.0,
626 dims: None,
627 })
628 .collect(),
629 }
630 }
631
632 /// Validate and bind ordered built-in peers without loading model services.
633 ///
634 /// Legacy programmatic model fields remain accepted when `engines` is
635 /// absent. Once prepared, those fields are projections of the peer list.
636 /// Custom provider startup binding is not yet enabled.
637 pub fn prepare_engines(&mut self) -> RuntimeResult<()> {
638 use crate::embedder_registry::{EmbedderProvider, LatticeEmbedderProvider};
639
640 let mut engines = self.configured_engines();
641 crate::engine_config::validate_peer_engines(self.engines.as_deref().unwrap_or(&engines))
642 .map_err(|error| crate::RuntimeError::InvalidInput(error.to_string()))?;
643
644 let mut models = Vec::with_capacity(engines.len());
645 for engine in &mut engines {
646 let model = parse_embedding_model_alias(&engine.name).ok_or_else(|| {
647 crate::RuntimeError::InvalidInput(format!(
648 "embedding provider `{}` cannot be bound at startup: custom provider \
649 configuration binding is not yet supported",
650 engine.name
651 ))
652 })?;
653 let provider = LatticeEmbedderProvider::new(model);
654 engine
655 .check_dimensions(provider.dimensions())
656 .map_err(|error| crate::RuntimeError::InvalidInput(error.to_string()))?;
657 engine.name = model.to_string();
658 models.push(model);
659 }
660 self.embedding_model = models.first().copied();
661 self.additional_embedding_models = models.into_iter().skip(1).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`
948pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
949 sanitize_key(&model.to_string())
950}
951
952/// Preserve ASCII letters and digits; replace every other Unicode character with `_`.
953/// Used for model-name table suffixes; this mapping does not preserve uniqueness.
954#[doc(hidden)]
955pub fn sanitize_key(s: &str) -> String {
956 s.chars()
957 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
958 .collect()
959}
960
961pub(crate) fn build_embedder_registry(
962 config: &RuntimeConfig,
963) -> (crate::embedder_registry::EmbedderRegistry, Arc<str>) {
964 use crate::embedder_registry::{EmbedderRegistry, LatticeEmbedderProvider};
965 let mut registry = EmbedderRegistry::new();
966 for model in configured_embedding_models(config) {
967 registry.register_builtin(LatticeEmbedderProvider::new(model));
968 }
969 let default_embedder_name = config
970 .embedding_model
971 .map(|model| Arc::<str>::from(model.to_string()))
972 .unwrap_or_else(|| Arc::<str>::from(""));
973 (registry, default_embedder_name)
974}
975
976fn configured_embedding_models(config: &RuntimeConfig) -> Vec<EmbeddingModel> {
977 if let Some(engines) = &config.engines {
978 return engines
979 .iter()
980 .filter_map(|engine| parse_embedding_model_alias(&engine.name))
981 .collect();
982 }
983 let mut models: Vec<EmbeddingModel> = Vec::new();
984 if let Some(model) = config.embedding_model {
985 models.push(model);
986 }
987 for model in config.additional_embedding_models.iter().copied() {
988 if !models.contains(&model) {
989 models.push(model);
990 }
991 }
992 models
993}
994
995pub(crate) fn register_configured_embedding_models(
996 backend: &StorageBackend,
997 config: &RuntimeConfig,
998) -> RuntimeResult<()> {
999 for model in configured_embedding_models(config) {
1000 backend.register_embedding_model(
1001 &model.to_string(),
1002 model.model_id(),
1003 model.key_version(),
1004 model.dimensions() as u32,
1005 )?;
1006 }
1007 Ok(())
1008}
1009
1010/// Build a `RuntimeConfig` from a parsed `KhiveConfig`.
1011///
1012/// Declared engines preserve their order and dimension assertions. The first
1013/// peer is projected into the legacy single-model field when it is built-in;
1014/// startup validates and binds the complete list before storage registration.
1015/// An explicit empty list disables participation; an absent list preserves
1016/// the caller's base configuration, including environment-derived defaults.
1017/// Load file input with [`crate::KhiveConfig::load`] to preserve that distinction
1018/// and convert legacy declarations before calling this function.
1019///
1020/// When both a config file and `KHIVE_EMBEDDING_MODEL` env var are present,
1021/// the caller is responsible for emitting a warning that env vars are overridden.
1022/// This function purely converts `KhiveConfig` to `RuntimeConfig` fields.
1023pub fn runtime_config_from_khive_config(
1024 khive_cfg: &crate::engine_config::KhiveConfig,
1025 base: RuntimeConfig,
1026) -> RuntimeConfig {
1027 // `[actor] id` never becomes the storage namespace (writes always pin to
1028 // `local`); it only widens the read visible-set below.
1029 let default_namespace = base.default_namespace.clone();
1030 let mounts = khive_cfg.mounts.clone();
1031 let credentials = khive_cfg.credentials.clone();
1032 let visibility_receipts = khive_cfg.visibility_receipts.clone();
1033
1034 // base.brain_profile must carry only the explicit CLI tier, never an env
1035 // value: env sits below toml in precedence and is applied later by the MCP resolver.
1036 let brain_profile = base.brain_profile.clone().or_else(|| {
1037 khive_cfg
1038 .runtime
1039 .brain_profile
1040 .clone()
1041 .filter(|s| !s.trim().is_empty())
1042 });
1043
1044 let visible_namespaces: Vec<Namespace> = khive_cfg
1045 .actor
1046 .visible_namespaces
1047 .as_deref()
1048 .unwrap_or_default()
1049 .iter()
1050 .filter_map(|s| match Namespace::parse(s) {
1051 Ok(ns) => Some(ns),
1052 Err(e) => {
1053 tracing::warn!(ns = %s, error = %e, "actor.visible_namespaces: invalid namespace; skipped");
1054 None
1055 }
1056 })
1057 .collect();
1058
1059 // Fold actor.id's namespace into visible_namespaces so default reads widen
1060 // to {local} ∪ {actor namespace}; skipped when it parses to `local` (would
1061 // duplicate the primary namespace already minted) or is already present.
1062 let visible_namespaces = if let Some(id) = khive_cfg.actor.id.as_deref() {
1063 match Namespace::parse(id) {
1064 Ok(actor_ns) if actor_ns != Namespace::local() => {
1065 let mut v = visible_namespaces;
1066 if !v.contains(&actor_ns) {
1067 v.push(actor_ns);
1068 }
1069 v
1070 }
1071 _ => visible_namespaces,
1072 }
1073 } else {
1074 visible_namespaces
1075 };
1076
1077 // KhiveConfig::validate() guarantees these are valid Namespace strings, so
1078 // parse failures here are unreachable for validated configs; filter_map+warn
1079 // guards against a validation bug panicking instead.
1080 let allowed_outbound_namespaces: Vec<Namespace> = khive_cfg
1081 .actor
1082 .allowed_outbound_namespaces
1083 .iter()
1084 .filter_map(|s| match Namespace::parse(s) {
1085 Ok(ns) => Some(ns),
1086 Err(e) => {
1087 tracing::warn!(ns = %s, error = %e, "actor.allowed_outbound_namespaces: invalid namespace; skipped");
1088 None
1089 }
1090 })
1091 .collect();
1092
1093 // Precedence: TOML `[actor] id` > `base.actor_id` (env/CLI-resolved) >
1094 // anonymous. Falls back to `base.actor_id` rather than `None` when
1095 // `[actor] id` is absent: otherwise an env-resolved actor like
1096 // `KHIVE_ACTOR` is silently dropped whenever a project config exists
1097 // without an `[actor]` block.
1098 let actor_id = khive_cfg
1099 .actor
1100 .id
1101 .clone()
1102 .filter(|s| !s.trim().is_empty())
1103 .or_else(|| base.actor_id.clone());
1104
1105 let gate = khive_cfg
1106 .gate
1107 .as_ref()
1108 .map(|gate| {
1109 Arc::new(khive_gate::CallerEnrollmentGate::with_write_denials(
1110 gate.granted_actors.clone(),
1111 gate.grant_unattributed,
1112 gate.deny_writes_for.clone(),
1113 )) as GateRef
1114 })
1115 .unwrap_or_else(|| base.gate.clone());
1116 let gate = crate::mailbox_view::configured_mailbox_gate(&khive_cfg.actor, gate);
1117
1118 let brain = khive_cfg.brain.clone();
1119 let git_write = khive_cfg.git_write.clone();
1120 let blob = crate::engine_config::BlobSectionConfig {
1121 file_transfers: khive_cfg.blob.file_transfers || base.blob.file_transfers,
1122 };
1123 let exec = khive_cfg.exec.clone();
1124 let telemetry = khive_cfg.telemetry.clone();
1125 let web = khive_cfg.web.clone();
1126 let blob_hydration_bytes = khive_cfg
1127 .runtime
1128 .blob_hydration_bytes
1129 .unwrap_or(base.blob_hydration_bytes);
1130
1131 // KhiveConfig::validate() guarantees a present timezone parses as a valid
1132 // chrono_tz::Tz, so the fallback to base.display_timezone below is only
1133 // reachable for an unvalidated caller-constructed KhiveConfig, not a
1134 // config loaded via KhiveConfig::load.
1135 let display_timezone = khive_cfg
1136 .display
1137 .timezone
1138 .as_deref()
1139 .and_then(|s| s.parse::<chrono_tz::Tz>().ok())
1140 .unwrap_or(base.display_timezone);
1141
1142 if khive_cfg.engines.is_empty() && !khive_cfg.engines_declared {
1143 return RuntimeConfig {
1144 credentials,
1145 visibility_receipts,
1146 default_namespace,
1147 brain_profile,
1148 visible_namespaces,
1149 allowed_outbound_namespaces,
1150 actor_id,
1151 gate,
1152 brain,
1153 git_write,
1154 blob,
1155 exec,
1156 telemetry,
1157 mounts,
1158 blob_hydration_bytes,
1159 display_timezone,
1160 web,
1161 ..base
1162 };
1163 }
1164
1165 let engines = khive_cfg.engines.clone();
1166 let embedding_model = engines
1167 .first()
1168 .and_then(|engine| parse_embedding_model_alias(&engine.name));
1169 let additional = engines
1170 .iter()
1171 .skip(1)
1172 .filter_map(|engine| parse_embedding_model_alias(&engine.name))
1173 .collect();
1174
1175 RuntimeConfig {
1176 credentials,
1177 visibility_receipts,
1178 engines: Some(engines),
1179 embedding_model,
1180 additional_embedding_models: additional,
1181 default_namespace,
1182 brain_profile,
1183 visible_namespaces,
1184 allowed_outbound_namespaces,
1185 actor_id,
1186 gate,
1187 brain,
1188 git_write,
1189 blob,
1190 exec,
1191 telemetry,
1192 mounts,
1193 blob_hydration_bytes,
1194 display_timezone,
1195 web,
1196 ..base
1197 }
1198}
1199
1200#[cfg(test)]
1201mod display_timezone_tests {
1202 use super::resolve_default_display_timezone;
1203
1204 // The host's actual zone is environment-dependent (CI runners are
1205 // typically UTC), so this only asserts the resolver always produces some
1206 // valid, non-panicking Tz — never that it matches a specific zone.
1207 #[test]
1208 fn resolve_default_display_timezone_never_panics() {
1209 let _tz = resolve_default_display_timezone();
1210 }
1211}
1212
1213/// Parse a comma- or whitespace-separated list of embedding model names.
1214fn parse_embedding_model_list(s: &str) -> Vec<EmbeddingModel> {
1215 parse_pack_list(s)
1216 .into_iter()
1217 .filter_map(|raw| {
1218 let parsed = parse_embedding_model_alias(&raw);
1219 if parsed.is_none() && !raw.trim().is_empty() {
1220 tracing::warn!(
1221 model = %raw,
1222 "KHIVE_ADDITIONAL_EMBEDDING_MODELS contains unknown model name; ignored. \
1223 Valid forms: short alias like 'paraphrase' or a fully-qualified key \
1224 from lattice_embed::EmbeddingModel::from_str."
1225 );
1226 }
1227 parsed
1228 })
1229 .collect()
1230}
1231
1232pub(crate) fn parse_embedding_model_alias(name: &str) -> Option<EmbeddingModel> {
1233 let normalized = name.trim().to_ascii_lowercase().replace('_', "-");
1234 match normalized.as_str() {
1235 "paraphrase" => Some(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2),
1236 _ => normalized.parse().ok(),
1237 }
1238}
1239
1240#[cfg(test)]
1241mod resolve_db_anchor_tests {
1242 use super::resolve_db_anchor;
1243
1244 #[test]
1245 fn memory_sentinel_maps_to_none() {
1246 assert_eq!(resolve_db_anchor(Some(":memory:")), None);
1247 }
1248
1249 #[test]
1250 fn explicit_path_maps_to_some() {
1251 assert_eq!(
1252 resolve_db_anchor(Some("/tmp/khive-anchor-test.db")),
1253 Some(std::path::PathBuf::from("/tmp/khive-anchor-test.db"))
1254 );
1255 }
1256
1257 #[test]
1258 fn absent_maps_to_home_default() {
1259 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
1260 let expected = std::path::PathBuf::from(format!("{home}/.khive/khive.db"));
1261 assert_eq!(resolve_db_anchor(None), Some(expected));
1262 }
1263}
1264
1265#[cfg(test)]
1266mod assert_db_anchor_consistent_tests {
1267 use super::{assert_captured_db_anchor_consistent, resolve_db_anchor};
1268 use crate::assert_db_anchor_consistent;
1269
1270 #[test]
1271 fn diverging_db_path_is_rejected_naming_both_paths() {
1272 let args_db = "/tmp/khive-anchor-guard-real.db";
1273 let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
1274 let wrong = std::path::PathBuf::from("/tmp/khive-anchor-guard-wrong.db");
1275
1276 let err =
1277 assert_captured_db_anchor_consistent(Some(wrong.as_path()), Some(anchor.as_path()))
1278 .expect_err("a resolved db_path diverging from the anchor must be rejected");
1279
1280 let msg = err.to_string();
1281 assert!(
1282 msg.contains(&wrong.display().to_string()),
1283 "error must name the resolved (wrong) path: {msg}"
1284 );
1285 assert!(
1286 msg.contains(&anchor.display().to_string()),
1287 "error must name the canonical anchor path: {msg}"
1288 );
1289 }
1290
1291 #[test]
1292 fn matching_explicit_db_path_passes() {
1293 let args_db = "/tmp/khive-anchor-guard-consistent.db";
1294 let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
1295 assert!(assert_captured_db_anchor_consistent(
1296 Some(anchor.as_path()),
1297 Some(anchor.as_path())
1298 )
1299 .is_ok());
1300 }
1301
1302 #[test]
1303 fn memory_sentinel_anchor_is_inert() {
1304 // `resolve_db_anchor(":memory:")` yields `None` — there is no canonical
1305 // path to assert against, so the guard passes regardless of what
1306 // `resolved_db_path` happens to carry.
1307 let bogus = std::path::PathBuf::from("/tmp/should-not-matter.db");
1308 assert!(assert_captured_db_anchor_consistent(Some(bogus.as_path()), None).is_ok());
1309 assert!(assert_captured_db_anchor_consistent(None, None).is_ok());
1310 }
1311
1312 #[test]
1313 fn normal_boot_with_db_unset_passes_silently() {
1314 // Mirrors a normal boot with `--db` unset: `resolve_db_anchor(None)`
1315 // always resolves to `Some(..)` (HOME-set or -unset both produce a
1316 // concrete anchor), so a runtime whose resolved `db_path` matches
1317 // passes silently.
1318 let anchor = resolve_db_anchor(None);
1319 assert!(assert_captured_db_anchor_consistent(anchor.as_deref(), anchor.as_deref()).is_ok());
1320 }
1321
1322 #[test]
1323 fn public_compatibility_wrapper_accepts_path_and_memory_sentinel() {
1324 let args_db = "/tmp/khive-anchor-guard-public-api.db";
1325 let anchor = resolve_db_anchor(Some(args_db)).expect("explicit path always anchors");
1326 assert!(assert_db_anchor_consistent(Some(anchor.as_path()), Some(args_db)).is_ok());
1327
1328 let unrelated = std::path::Path::new("/tmp/khive-anchor-guard-unrelated.db");
1329 assert!(assert_db_anchor_consistent(Some(unrelated), Some(":memory:")).is_ok());
1330 }
1331}
1332
1333#[cfg(test)]
1334mod resolve_project_actor_id_tests {
1335 use super::resolve_project_actor_id;
1336
1337 fn write_toml(dir: &tempfile::TempDir, body: &str) -> std::path::PathBuf {
1338 let path = dir.path().join("config.toml");
1339 std::fs::write(&path, body).expect("write config.toml");
1340 path
1341 }
1342
1343 #[test]
1344 fn extracts_non_empty_actor_id_from_explicit_path() {
1345 let dir = tempfile::tempdir().expect("tempdir");
1346 let path = write_toml(&dir, "[actor]\nid = \"lambda:explicit-actor\"\n");
1347
1348 assert_eq!(
1349 resolve_project_actor_id(Some(&path)).expect("no error"),
1350 Some("lambda:explicit-actor".to_string())
1351 );
1352 }
1353
1354 #[test]
1355 fn missing_explicit_path_fails_loud() {
1356 // The explicit tier is enforced inside the loader
1357 // (`KhiveConfig::load_with_home_fallback_and_source` returns
1358 // `ExplicitConfigMissing`): a nonexistent explicit path is an error,
1359 // never a silent `None` (ADR-035).
1360 let missing = std::path::PathBuf::from("/nonexistent/khive-project-actor-test/config.toml");
1361 let err = resolve_project_actor_id(Some(&missing))
1362 .expect_err("a missing explicit path must fail loud");
1363 assert!(
1364 matches!(
1365 err,
1366 crate::engine_config::ConfigError::ExplicitConfigMissing { .. }
1367 ),
1368 "expected ExplicitConfigMissing, got {err:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn propagates_load_error_for_invalid_actor_id() {
1374 // `KhiveConfig::load`'s `validate()` rejects an empty `[actor] id` before
1375 // the emptiness filter in `resolve_project_actor_id` ever sees it; this
1376 // asserts the error surfaces rather than being swallowed into `Ok(None)`.
1377 let dir = tempfile::tempdir().expect("tempdir");
1378 let path = write_toml(&dir, "[actor]\nid = \"\"\n");
1379
1380 let err = resolve_project_actor_id(Some(&path)).expect_err("invalid actor.id must error");
1381 let root = match &err {
1382 crate::engine_config::ConfigError::InFile { source, .. } => source.as_ref(),
1383 other => other,
1384 };
1385 assert!(
1386 matches!(
1387 root,
1388 crate::engine_config::ConfigError::InvalidActorId { .. }
1389 ),
1390 "expected InvalidActorId, got {err:?}"
1391 );
1392 }
1393
1394 #[test]
1395 fn returns_none_when_config_has_no_actor_section() {
1396 let dir = tempfile::tempdir().expect("tempdir");
1397 let path = write_toml(
1398 &dir,
1399 "[[engines]]\nname = \"primary\"\nmodel = \"bge-small-en-v1.5\"\ndefault = true\n",
1400 );
1401
1402 assert_eq!(
1403 resolve_project_actor_id(Some(&path)).expect("no error"),
1404 None,
1405 "a config file with no [actor] section must resolve to None"
1406 );
1407 }
1408}
1409
1410#[cfg(test)]
1411mod no_embeddings_tests {
1412 use super::*;
1413 use serial_test::serial;
1414
1415 #[test]
1416 fn default_embedding_model_environment_keeps_typed_fallbacks() {
1417 if crate::test_process::run_in_child() {
1418 return;
1419 }
1420 const PRIMARY: &str = "KHIVE_EMBEDDING_MODEL";
1421 const ADDITIONAL: &str = "KHIVE_ADDITIONAL_EMBEDDING_MODELS";
1422 assert_eq!(
1423 RuntimeConfig::default().embedding_model,
1424 Some(EmbeddingModel::AllMiniLmL6V2)
1425 );
1426 std::env::set_var(ADDITIONAL, "paraphrase-multilingual-minilm-l12-v2");
1427 for (value, expected) in [
1428 ("invalid", EmbeddingModel::AllMiniLmL6V2),
1429 ("", EmbeddingModel::AllMiniLmL6V2),
1430 ("bge-small-en-v1.5", EmbeddingModel::BgeSmallEnV15),
1431 (" BAAI/BGE_SMALL_EN_V1.5 ", EmbeddingModel::BgeSmallEnV15),
1432 ("all-minilm", EmbeddingModel::AllMiniLmL6V2),
1433 ] {
1434 std::env::set_var(PRIMARY, value);
1435 let config = RuntimeConfig::default();
1436 assert_eq!(config.embedding_model, Some(expected), "{value:?}");
1437 assert_eq!(
1438 config.additional_embedding_models,
1439 vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]
1440 );
1441 }
1442 #[cfg(unix)]
1443 {
1444 use std::os::unix::ffi::OsStrExt;
1445 std::env::set_var(PRIMARY, std::ffi::OsStr::from_bytes(b"\xff"));
1446 assert_eq!(
1447 RuntimeConfig::default().embedding_model,
1448 Some(EmbeddingModel::AllMiniLmL6V2)
1449 );
1450 }
1451 std::env::remove_var(PRIMARY);
1452 let config = RuntimeConfig::default();
1453 assert_eq!(config.embedding_model, Some(EmbeddingModel::AllMiniLmL6V2));
1454 assert_eq!(
1455 config.additional_embedding_models,
1456 vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]
1457 );
1458 std::env::remove_var(ADDITIONAL);
1459 }
1460
1461 #[test]
1462 fn no_embeddings_clears_both_fields() {
1463 let config = RuntimeConfig::no_embeddings();
1464 assert_eq!(config.embedding_model, None);
1465 assert!(config.additional_embedding_models.is_empty());
1466 assert!(
1467 configured_embedding_models(&config).is_empty(),
1468 "no_embeddings() must yield zero configured embedders"
1469 );
1470 }
1471
1472 #[test]
1473 fn blob_hydration_default_is_four_portable_whole_objects() {
1474 assert_eq!(
1475 RuntimeConfig::default().blob_hydration_bytes,
1476 4 * khive_storage::MAX_BLOB_WHOLE_BYTES
1477 );
1478 }
1479
1480 #[test]
1481 #[serial]
1482 fn no_embeddings_ignores_additional_env_override() {
1483 // no_embeddings() is an unconditional opt-out: even if the caller's
1484 // environment sets KHIVE_ADDITIONAL_EMBEDDING_MODELS, the resulting
1485 // config must still report zero embedders.
1486 std::env::set_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS", "paraphrase");
1487 let config = RuntimeConfig::no_embeddings();
1488 std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
1489
1490 assert!(config.additional_embedding_models.is_empty());
1491 assert!(configured_embedding_models(&config).is_empty());
1492 }
1493
1494 #[test]
1495 #[serial]
1496 fn default_computes_additional_models_independently_of_no_embeddings() {
1497 // `Default` must keep computing `embedding_model` and
1498 // `additional_embedding_models` independently; `no_embeddings()` is a
1499 // separate opt-out constructor, not a change to `Default`'s seeding.
1500 // The env var is SET here rather than cleared: since Default now ships
1501 // no secondary engine, an unset env would make Default and
1502 // no_embeddings() indistinguishable and the test would stop
1503 // discriminating the thing it exists to discriminate.
1504 std::env::set_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS", "paraphrase");
1505 let config = RuntimeConfig::default();
1506 let buggy_form = RuntimeConfig {
1507 embedding_model: None,
1508 ..RuntimeConfig::default()
1509 };
1510 std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
1511
1512 assert_eq!(
1513 config.additional_embedding_models,
1514 vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]
1515 );
1516
1517 // Overriding only `embedding_model` via struct-update syntax does not
1518 // clear `additional_embedding_models`.
1519 assert!(
1520 !buggy_form.additional_embedding_models.is_empty(),
1521 "Default's independent-field seeding must remain unchanged; \
1522 no_embeddings() is the fix, not a change to Default"
1523 );
1524 }
1525
1526 #[test]
1527 #[serial]
1528 fn default_ships_a_single_engine_when_env_unset() {
1529 // A secondary engine is embedded on every write and searched on every
1530 // read. That cost is opted into, not inherited.
1531 std::env::remove_var("KHIVE_ADDITIONAL_EMBEDDING_MODELS");
1532 let config = RuntimeConfig::default();
1533
1534 assert!(
1535 config.additional_embedding_models.is_empty(),
1536 "shipped default must register one engine; a second is opt-in via \
1537 KHIVE_ADDITIONAL_EMBEDDING_MODELS"
1538 );
1539 assert_eq!(
1540 configured_embedding_models(&config),
1541 vec![EmbeddingModel::AllMiniLmL6V2]
1542 );
1543 }
1544}
1545
1546#[cfg(test)]
1547mod ann_fresh_tail_config_tests {
1548 use super::ann_fresh_tail_enabled_from_value;
1549
1550 #[test]
1551 fn only_exact_zero_disables_fresh_tail() {
1552 assert!(ann_fresh_tail_enabled_from_value(None));
1553 assert!(!ann_fresh_tail_enabled_from_value(Some("0")));
1554 assert!(ann_fresh_tail_enabled_from_value(Some("1")));
1555 assert!(ann_fresh_tail_enabled_from_value(Some("false")));
1556 assert!(ann_fresh_tail_enabled_from_value(Some(" 0")));
1557 }
1558}
1559
1560#[cfg(test)]
1561mod configured_embedding_models_order_tests {
1562 use super::*;
1563
1564 /// Issue #1115: the configured engine list must preserve declaration
1565 /// order (primary first, then `additional_embedding_models` in order)
1566 /// instead of alphabetizing — any consumer that treats the list as
1567 /// ordered (e.g. recall fan-out) otherwise gets the wrong primary.
1568 #[test]
1569 fn preserves_primary_first_then_additional_in_declared_order() {
1570 let config = RuntimeConfig {
1571 embedding_model: Some(EmbeddingModel::AllMiniLmL6V2),
1572 additional_embedding_models: vec![
1573 EmbeddingModel::Qwen3Embedding4B,
1574 EmbeddingModel::BgeSmallEnV15,
1575 ],
1576 ..RuntimeConfig::default()
1577 };
1578
1579 assert_eq!(
1580 configured_embedding_models(&config),
1581 vec![
1582 EmbeddingModel::AllMiniLmL6V2,
1583 EmbeddingModel::Qwen3Embedding4B,
1584 EmbeddingModel::BgeSmallEnV15,
1585 ],
1586 "order must be primary-first, then additional models as declared, \
1587 not alphabetized"
1588 );
1589 }
1590
1591 /// A model repeated in both `embedding_model` and `additional_embedding_models`
1592 /// must be deduped to a single entry, keeping its first (primary) position.
1593 #[test]
1594 fn dedupes_model_shared_between_primary_and_additional() {
1595 let config = RuntimeConfig {
1596 embedding_model: Some(EmbeddingModel::AllMiniLmL6V2),
1597 additional_embedding_models: vec![
1598 EmbeddingModel::AllMiniLmL6V2,
1599 EmbeddingModel::BgeSmallEnV15,
1600 ],
1601 ..RuntimeConfig::default()
1602 };
1603
1604 assert_eq!(
1605 configured_embedding_models(&config),
1606 vec![EmbeddingModel::AllMiniLmL6V2, EmbeddingModel::BgeSmallEnV15],
1607 "the shared model must appear once, in its primary position"
1608 );
1609 }
1610}