Skip to main content

khive_runtime/
config.rs

1//! RuntimeConfig, BackendId, NamespaceToken, and embedding model helpers.
2
3use std::sync::Arc;
4
5use khive_db::StorageBackend;
6use khive_gate::{ActorRef, AllowAllGate, GateRef};
7use khive_types::Namespace;
8use lattice_embed::EmbeddingModel;
9
10use crate::error::RuntimeResult;
11
12// ---- BackendId ----
13
14/// Identifies a named backend in a multi-backend deployment.
15///
16/// The `main` backend is the default single-backend name. Multi-backend deployments
17/// assign each `[[backends]]` entry a distinct `BackendId`. The
18/// `SubstrateCoordinator` in `kkernel`
19/// uses `BackendId` for node-to-backend resolution and cross-backend edge routing.
20///
21/// A single-backend `KhiveRuntime` always has `BackendId("main")` by default.
22/// The boot path in `kkernel` or `khive-mcp` sets the id via `RuntimeConfig::backend_id`
23/// when constructing per-pack runtimes.
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25pub struct BackendId(pub String);
26
27impl BackendId {
28    /// The default single-backend name.
29    pub const MAIN: &'static str = "main";
30
31    /// Construct from a string name.
32    pub fn new(name: impl Into<String>) -> Self {
33        Self(name.into())
34    }
35
36    /// The default `main` backend id.
37    pub fn main() -> Self {
38        Self(Self::MAIN.to_string())
39    }
40
41    /// Return the backend name as a `&str`.
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl std::fmt::Display for BackendId {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.write_str(&self.0)
50    }
51}
52
53// ---- Sealed token ----
54
55mod private {
56    #[derive(Clone, Debug)]
57    pub(crate) struct Sealed;
58}
59
60/// Authorization proof that a caller is permitted to access a specific namespace.
61///
62/// Created by [`crate::VerbRegistry::dispatch`] after the gate approves the request.
63/// The sealed inner field prevents external code from constructing a token
64/// without going through the authorization path.
65///
66/// The `namespace` field is the **write namespace**: all records created via
67/// this token land in that namespace. `visible` is the **read visibility set**:
68/// list/search/get operations will return records from any namespace in this
69/// set. The write namespace is always a member of the visible set.
70///
71/// Single-namespace behaviour (backward-compatible default): `visible` contains
72/// exactly `[namespace]` — identical to the old strict-equality checks.
73#[derive(Clone, Debug)]
74pub struct NamespaceToken {
75    namespace: Namespace,
76    visible: Vec<Namespace>,
77    actor: ActorRef,
78    _sealed: private::Sealed,
79}
80
81impl NamespaceToken {
82    /// Mint an authorized token with an extended visibility set.
83    ///
84    /// `extra_visible` lists namespaces beyond the primary that the token may
85    /// read. The primary namespace is always included in the visible set
86    /// regardless of what `extra_visible` contains. Duplicates are removed.
87    pub(crate) fn mint_with_visibility(
88        namespace: Namespace,
89        extra_visible: Vec<Namespace>,
90        actor: ActorRef,
91    ) -> Self {
92        let mut visible = vec![namespace.clone()];
93        for ns in extra_visible {
94            if !visible.contains(&ns) {
95                visible.push(ns);
96            }
97        }
98        debug_assert!(!visible.is_empty(), "visible set must be non-empty");
99        Self {
100            namespace,
101            visible,
102            actor,
103            _sealed: private::Sealed,
104        }
105    }
106
107    /// Mint an authorized token. Only callable from within `khive-runtime`.
108    ///
109    /// The visible set defaults to `[namespace]` — backward-compatible with
110    /// single-namespace enforcement.
111    pub(crate) fn mint_authorized(namespace: Namespace, actor: ActorRef) -> Self {
112        Self::mint_with_visibility(namespace, vec![], actor)
113    }
114
115    /// Convenience constructor for the local namespace with an anonymous actor.
116    ///
117    /// Only callable from within `khive-runtime`. External callers must use
118    /// [`KhiveRuntime::authorize`] to mint tokens.
119    // Used only in #[cfg(test)] blocks within this crate's src/ files.
120    #[allow(dead_code)]
121    pub(crate) fn local() -> Self {
122        Self::mint_authorized(Namespace::local(), ActorRef::anonymous())
123    }
124
125    /// Convenience constructor for a specific namespace with an anonymous actor.
126    ///
127    /// Only callable from within `khive-runtime`. External callers must use
128    /// [`KhiveRuntime::authorize`] to mint tokens.
129    // Used only in #[cfg(test)] blocks within this crate's src/ files.
130    #[allow(dead_code)]
131    pub(crate) fn for_namespace(ns: Namespace) -> Self {
132        Self::mint_authorized(ns, ActorRef::anonymous())
133    }
134
135    /// Return the write namespace this token authorises.
136    ///
137    /// All records created via this token land in this namespace.
138    pub fn namespace(&self) -> &Namespace {
139        &self.namespace
140    }
141
142    /// Return the read-visibility set.
143    ///
144    /// List, search, and get operations must accept records whose namespace is
145    /// a member of this set. The write namespace is always included.
146    pub fn visible_namespaces(&self) -> &[Namespace] {
147        &self.visible
148    }
149
150    /// Return a deduplicated list of visible namespace strings (borrowed).
151    ///
152    /// Convenience for passing directly to storage layer filters.
153    pub fn visible_namespace_strs(&self) -> Vec<&str> {
154        self.visible.iter().map(|ns| ns.as_str()).collect()
155    }
156
157    /// Return the actor reference embedded in this token.
158    pub fn actor(&self) -> &ActorRef {
159        &self.actor
160    }
161
162    /// Return a new token with the same actor but a different namespace.
163    ///
164    /// The visible set is replaced with `[ns]` — the minted token has
165    /// `namespace = ns` and `visible = [ns]`. This is a full read+write token
166    /// for `ns`: public runtime APIs (`list_notes`, `update_note`, `delete_note`,
167    /// etc.) accept it and will operate on `ns`. It is NOT a type-enforced
168    /// write-only or append-only capability. This is a capability-transfer
169    /// primitive, not a policy gate: the caller is responsible for enforcing any
170    /// ACL check before calling this method and for using the minted token only
171    /// in the intended narrow scope (e.g. a single `create_note` call).
172    ///
173    /// Callers today:
174    /// - `khive-pack-memory` FTS fanout: iterates token's own visible set; no escalation.
175    /// - `khive-pack-comm` inbound delivery: mints a token for the recipient ns,
176    ///   gated by `actor.allowed_outbound_namespaces` allowlist check immediately
177    ///   before, and uses it for exactly one `create_note` call (append-only by
178    ///   convention, not by type enforcement).
179    ///
180    /// Under a security model (cloud, mutual auth), replace this call pattern with a
181    /// type-enforced append-only capability or a `comm.ingest` Subhandler dispatch
182    /// (see ADR-056/ADR-053) that goes through the Gate.
183    pub fn with_namespace(&self, ns: Namespace) -> Self {
184        Self::mint_authorized(ns, self.actor.clone())
185    }
186}
187
188// ---- RuntimeConfig ----
189
190/// Runtime configuration.
191///
192/// The `db_path` and `embedding_model` fields are deprecated in favour of
193/// constructing the backend externally and calling [`crate::KhiveRuntime::from_backend`].
194/// They remain for backward compatibility with tests and single-binary deployments.
195#[derive(Clone, Debug)]
196pub struct RuntimeConfig {
197    /// Path to the SQLite database file. `None` = in-memory (tests).
198    ///
199    /// Deprecated: use [`crate::KhiveRuntime::from_backend`] instead. The boot path
200    /// constructs backends from `khive.toml` (`AppConfig`) and passes them to
201    /// `from_backend`. Direct `db_path` usage persists only in tests.
202    pub db_path: Option<std::path::PathBuf>,
203    /// Namespace used when no explicit namespace is provided.
204    pub default_namespace: Namespace,
205    /// Local embedding model. `None` disables embedding and hybrid vector search;
206    /// `hybrid_search` then falls back to text-only.
207    ///
208    /// Deprecated: embedding engines move to a per-pack `EmbedderRegistry`.
209    /// This field persists for backward compatibility until the embedder registry
210    /// is fully plumbed.
211    pub embedding_model: Option<EmbeddingModel>,
212    /// Additional embedding models to make available by request name.
213    ///
214    /// `embedding_model` remains the default used by existing `embed()` and
215    /// `embed_batch()` callers. This list adds non-default models that can be
216    /// selected with `embedder(name)`, `embed_with_model(...)`, memory
217    /// `remember.embedding_model`, and memory `recall.embedding_model`.
218    pub additional_embedding_models: Vec<EmbeddingModel>,
219    /// Authorization gate consulted before each verb dispatch.
220    /// Default: `AllowAllGate` (permissive). For production policy enforcement,
221    /// plug in a Rego- or capability-witness-backed impl.
222    pub gate: GateRef,
223    /// Names of packs the transport layer should register into the VerbRegistry.
224    /// The transport layer (e.g. `khive-mcp`) reads this list and instantiates
225    /// the matching concrete pack types. Unknown names are reported as errors
226    /// by the transport, not silently ignored.
227    /// Default: `["kg"]`.
228    pub packs: Vec<String>,
229    /// Identifies this runtime's backend in a multi-backend deployment.
230    ///
231    /// Set by the boot path when constructing per-pack runtimes from `khive.toml`.
232    /// Single-backend deployments use the default `BackendId::MAIN`.
233    pub backend_id: BackendId,
234    /// Brain profile to use for `memory.feedback` / `knowledge.feedback` and
235    /// recall-time score boosting (ADR-035 §Brain profile configuration).
236    ///
237    /// Resolution order (highest to lowest, ADR-035): CLI flag, then
238    /// `runtime.brain_profile` in project/global `khive.toml`, then the
239    /// `KHIVE_BRAIN_PROFILE` env var as fallback default. Callers must keep
240    /// env OUT of the base config they pass in (see `khive-mcp` serve.rs).
241    /// 1. `--brain-profile` CLI flag (explicit only)
242    /// 2. Namespace-bound profile resolved via `brain.resolve` at feedback time
243    /// 3. Pack-local global tuning prior (default fallback)
244    pub brain_profile: Option<String>,
245    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
246    ///
247    /// OSS dispatch widens the DEFAULT multi-record read scope to
248    /// `['local'] ∪ visible_namespaces`. Writes remain pinned to `'local'`.
249    /// An explicit `namespace=` request param is a precise single-namespace
250    /// escape and is not widened. Populated from `actor.visible_namespaces`
251    /// in `khive.toml`.
252    pub visible_namespaces: Vec<Namespace>,
253    /// Namespaces this actor's comm.send/reply may deliver messages INTO
254    /// (outbound, sender-side). Populated from `actor.allowed_outbound_namespaces`
255    /// in `khive.toml`. Empty by default — cross-namespace delivery denied
256    /// unless explicitly declared. The comm handler uses an ordinary
257    /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
258    /// the token itself is NOT type-enforced write-only. The recipient-side
259    /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
260    /// a future cloud-path authorization ADR (not yet written).
261    pub allowed_outbound_namespaces: Vec<Namespace>,
262    /// Configured actor identity label (ADR-057). Populated from `[actor] id` in
263    /// `khive.toml`. When `Some`, `authorize()` mints tokens carrying this actor
264    /// label so that `comm.inbox` filters by `to_actor` instead of falling back to
265    /// the party-line "local" behavior. When `None` (default), tokens carry
266    /// `ActorRef::anonymous()` and inbox shows all inbound messages.
267    pub actor_id: Option<String>,
268}
269
270/// Parse a comma- or whitespace-separated pack list from a single string.
271///
272/// Empty entries are dropped, surrounding whitespace is trimmed.
273pub fn parse_pack_list(s: &str) -> Vec<String> {
274    s.split(|c: char| c == ',' || c.is_whitespace())
275        .map(str::trim)
276        .filter(|s| !s.is_empty())
277        .map(str::to_owned)
278        .collect()
279}
280
281impl Default for RuntimeConfig {
282    fn default() -> Self {
283        let db_path = std::env::var("HOME")
284            .ok()
285            .map(|h| std::path::PathBuf::from(h).join(".khive/khive.db"));
286        let embedding_model = std::env::var("KHIVE_EMBEDDING_MODEL")
287            .ok()
288            .and_then(|s| s.parse().ok())
289            .or(Some(EmbeddingModel::AllMiniLmL6V2));
290        let additional_embedding_models = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
291            .ok()
292            .map(|s| parse_embedding_model_list(&s))
293            .unwrap_or_else(|| vec![EmbeddingModel::ParaphraseMultilingualMiniLmL12V2]);
294        let packs = std::env::var("KHIVE_PACKS")
295            .ok()
296            .map(|s| parse_pack_list(&s))
297            .filter(|v| !v.is_empty())
298            .unwrap_or_else(|| {
299                vec![
300                    "kg",
301                    "gtd",
302                    "memory",
303                    "brain",
304                    "comm",
305                    "schedule",
306                    "knowledge",
307                    "session",
308                ]
309                .into_iter()
310                .map(String::from)
311                .collect()
312            });
313        let brain_profile = std::env::var("KHIVE_BRAIN_PROFILE")
314            .ok()
315            .filter(|s| !s.trim().is_empty());
316        let actor_id = std::env::var("KHIVE_ACTOR")
317            .ok()
318            .filter(|s| !s.trim().is_empty());
319        Self {
320            db_path,
321            default_namespace: Namespace::local(),
322            embedding_model,
323            additional_embedding_models,
324            gate: Arc::new(AllowAllGate),
325            packs,
326            backend_id: BackendId::main(),
327            brain_profile,
328            visible_namespaces: vec![],
329            allowed_outbound_namespaces: vec![],
330            actor_id,
331        }
332    }
333}
334
335// ---- Embedding model helpers ----
336
337/// Sanitize an embedding model name into a valid SQL table suffix.
338/// e.g. `bge-small-en-v1.5` -> `bge_small_en_v1_5`
339pub(crate) fn vec_model_key(model: EmbeddingModel) -> String {
340    sanitize_key(&model.to_string())
341}
342
343pub(crate) fn sanitize_key(s: &str) -> String {
344    s.chars()
345        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
346        .collect()
347}
348
349pub(crate) fn build_embedder_registry(
350    config: &RuntimeConfig,
351) -> (crate::embedder_registry::EmbedderRegistry, Arc<str>) {
352    use crate::embedder_registry::{EmbedderRegistry, LatticeEmbedderProvider};
353    let mut registry = EmbedderRegistry::new();
354    for model in configured_embedding_models(config) {
355        registry.register(LatticeEmbedderProvider::new(model));
356    }
357    let default_embedder_name = config
358        .embedding_model
359        .map(|model| Arc::<str>::from(model.to_string()))
360        .unwrap_or_else(|| Arc::<str>::from(""));
361    (registry, default_embedder_name)
362}
363
364fn configured_embedding_models(config: &RuntimeConfig) -> Vec<EmbeddingModel> {
365    let mut models = Vec::new();
366    if let Some(model) = config.embedding_model {
367        models.push(model);
368    }
369    models.extend(config.additional_embedding_models.iter().copied());
370    models.sort_by_key(|model| model.to_string());
371    models.dedup();
372    models
373}
374
375pub(crate) fn register_configured_embedding_models(
376    backend: &StorageBackend,
377    config: &RuntimeConfig,
378) -> RuntimeResult<()> {
379    for model in configured_embedding_models(config) {
380        backend.register_embedding_model(
381            &model.to_string(),
382            model.model_id(),
383            model.key_version(),
384            model.dimensions() as u32,
385        )?;
386    }
387    Ok(())
388}
389
390/// Build a `RuntimeConfig` from a parsed `KhiveConfig`.
391///
392/// For each `[[engines]]` entry:
393/// - The engine flagged `default = true` becomes `RuntimeConfig::embedding_model`.
394/// - All other engines become `RuntimeConfig::additional_embedding_models`.
395///
396/// Model name validity is checked here: any engine whose `model` field cannot
397/// be parsed via `parse_embedding_model_alias` is skipped with a warning.
398///
399/// If `khive_cfg.engines` is empty, the returned `RuntimeConfig` uses the
400/// env-var-derived defaults from `RuntimeConfig::default()`.
401///
402/// When both a config file and `KHIVE_EMBEDDING_MODEL` env var are present,
403/// the caller is responsible for emitting a warning that env vars are ignored.
404/// This function purely converts `KhiveConfig` to `RuntimeConfig` fields.
405pub fn runtime_config_from_khive_config(
406    khive_cfg: &crate::engine_config::KhiveConfig,
407    base: RuntimeConfig,
408) -> RuntimeConfig {
409    // ADR-007 Rev 4 Rule 0: `[actor] id` does NOT become the storage namespace
410    // (writes always pin to `local`). `default_namespace` is whatever the caller
411    // resolved into `base` (explicit `--namespace` / `KHIVE_NAMESPACE`, else `local`).
412    // `actor.id` contributes to the read visible-set only (see fold-in below).
413    let default_namespace = base.default_namespace.clone();
414
415    // base.brain_profile must carry ONLY the explicit CLI tier — never an env
416    // value (env sits BELOW toml per ADR-035; the MCP resolver applies it after).
417    let brain_profile = base.brain_profile.clone().or_else(|| {
418        khive_cfg
419            .runtime
420            .brain_profile
421            .clone()
422            .filter(|s| !s.trim().is_empty())
423    });
424
425    let visible_namespaces: Vec<Namespace> = khive_cfg
426        .actor
427        .visible_namespaces
428        .as_deref()
429        .unwrap_or_default()
430        .iter()
431        .filter_map(|s| match Namespace::parse(s) {
432            Ok(ns) => Some(ns),
433            Err(e) => {
434                tracing::warn!(ns = %s, error = %e, "actor.visible_namespaces: invalid namespace; skipped");
435                None
436            }
437        })
438        .collect();
439
440    // ADR-007 Rev 4: fold actor.id's namespace into visible_namespaces so that
441    // default reads widen to {local} ∪ {actor namespace}. Skipped when actor.id
442    // parses to `local` (mint already includes primary=local on the default path,
443    // adding it here would create a duplicate). Also skipped when it is already
444    // present from actor.visible_namespaces above.
445    let visible_namespaces = if let Some(id) = khive_cfg.actor.id.as_deref() {
446        match Namespace::parse(id) {
447            Ok(actor_ns) if actor_ns != Namespace::local() => {
448                let mut v = visible_namespaces;
449                if !v.contains(&actor_ns) {
450                    v.push(actor_ns);
451                }
452                v
453            }
454            _ => visible_namespaces,
455        }
456    } else {
457        visible_namespaces
458    };
459
460    // KhiveConfig::validate() guarantees every entry in allowed_outbound_namespaces is a
461    // structurally valid Namespace string, so Namespace::parse failures here are unreachable
462    // for validated configs. We still filter_map with a warn so a runtime bug doesn't panic.
463    let allowed_outbound_namespaces: Vec<Namespace> = khive_cfg
464        .actor
465        .allowed_outbound_namespaces
466        .iter()
467        .filter_map(|s| match Namespace::parse(s) {
468            Ok(ns) => Some(ns),
469            Err(e) => {
470                tracing::warn!(ns = %s, error = %e, "actor.allowed_outbound_namespaces: invalid namespace; skipped");
471                None
472            }
473        })
474        .collect();
475
476    // ADR-057: store actor.id as actor_id for token minting. The validated id is
477    // already confirmed to be a valid Namespace string by KhiveConfig::validate().
478    // None when [actor] id is absent — tokens then carry ActorRef::anonymous().
479    let actor_id = khive_cfg.actor.id.clone().filter(|s| !s.trim().is_empty());
480
481    if khive_cfg.engines.is_empty() {
482        return RuntimeConfig {
483            default_namespace,
484            brain_profile,
485            visible_namespaces,
486            allowed_outbound_namespaces,
487            actor_id,
488            ..base
489        };
490    }
491
492    let mut embedding_model: Option<EmbeddingModel> = None;
493    let mut additional: Vec<EmbeddingModel> = Vec::new();
494
495    for engine in &khive_cfg.engines {
496        match parse_embedding_model_alias(&engine.model) {
497            Some(model) => {
498                if engine.default {
499                    embedding_model = Some(model);
500                } else {
501                    additional.push(model);
502                }
503            }
504            None => {
505                tracing::warn!(
506                    engine = %engine.name,
507                    model = %engine.model,
508                    "engine config: unknown model name; engine will be skipped"
509                );
510            }
511        }
512    }
513
514    RuntimeConfig {
515        embedding_model,
516        additional_embedding_models: additional,
517        default_namespace,
518        brain_profile,
519        visible_namespaces,
520        allowed_outbound_namespaces,
521        actor_id,
522        ..base
523    }
524}
525
526/// Parse a comma- or whitespace-separated list of embedding model names.
527fn parse_embedding_model_list(s: &str) -> Vec<EmbeddingModel> {
528    parse_pack_list(s)
529        .into_iter()
530        .filter_map(|raw| {
531            let parsed = parse_embedding_model_alias(&raw);
532            if parsed.is_none() && !raw.trim().is_empty() {
533                tracing::warn!(
534                    model = %raw,
535                    "KHIVE_ADDITIONAL_EMBEDDING_MODELS contains unknown model name; ignored. \
536                     Valid forms: short alias like 'paraphrase' or a fully-qualified key \
537                     from lattice_embed::EmbeddingModel::from_str."
538                );
539            }
540            parsed
541        })
542        .collect()
543}
544
545pub(crate) fn parse_embedding_model_alias(name: &str) -> Option<EmbeddingModel> {
546    let normalized = name.trim().to_ascii_lowercase().replace('_', "-");
547    match normalized.as_str() {
548        "paraphrase" => Some(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2),
549        _ => normalized.parse().ok(),
550    }
551}