use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use khive_types::{namespace::Namespace, SubstrateKind};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
config::{parse_embedding_model_alias, BackendId},
presentation::OutputFormat,
};
#[path = "engine_config_backend_disk_guard.rs"]
mod backend_disk_guard;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error(transparent)]
Credential(#[from] crate::credentials::CredentialError),
#[error("mount configuration: {reason}")]
InvalidMountConfig { reason: String },
#[error("config file I/O: {0}")]
Io(#[from] std::io::Error),
#[error("config TOML parse error in {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("exactly one engine must be marked `default = true`; found {found}")]
DefaultCount { found: usize },
#[error("duplicate engine name: {name:?}")]
DuplicateName { name: String },
#[error(
"engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
)]
UnknownModel { name: String, model: String },
#[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
InvalidFusionWeight { name: String, value: f64 },
#[error(
"engine {name:?}: fusion_weight is not applied by current retrieval; \
remove it until weighted multi-engine fusion is wired"
)]
UnsupportedFusionWeight { name: String },
#[error("actor.id {id:?} is not a valid namespace: {reason}")]
InvalidActorId { id: String, reason: String },
#[error("[actor].mailbox_readers: {reason}")]
InvalidMailboxReaders { reason: String },
#[error("[gate].granted_actors entry {id:?} is not a valid actor id: {reason}")]
InvalidGrantedActorId { id: String, reason: String },
#[error("[gate].deny_writes_for is invalid: {reason}")]
InvalidWriteDenyPatterns { reason: String },
#[error("duplicate backend name: {name:?}")]
DuplicateBackendName { name: String },
#[error("invalid backend name {name:?}: {reason}")]
InvalidBackendName { name: String, reason: String },
#[error("backend {name:?}: `served_kinds` must not be empty when declared")]
EmptyBackendServedKinds { name: String },
#[error("backend {name:?}: invalid disk guard configuration: {reason}")]
InvalidBackendDiskGuard { name: String, reason: String },
#[error(
"backends {first_backend:?} and {second_backend:?} name the same database but resolve \
different disk reserve/deadline policies"
)]
DiskGuardAliasConflict {
first_backend: String,
second_backend: String,
},
#[error("KHIVE_SQLITE_WAL_CEILING_BYTES must be an unsigned decimal byte count")]
InvalidWalCeilingEnvironment { value: String },
#[error(
"backend {name:?}: wal_ceiling_bytes {value} exceeds supported SQLite offset arithmetic"
)]
WalCeilingOffsetOverflow { name: String, value: u64 },
#[error(
"backend {name:?}: nonzero wal_ceiling_bytes {value} requires a file-backed SQLite backend"
)]
WalCeilingMemoryBackend { name: String, value: u64 },
#[error("backend {name:?}: nonzero wal_ceiling_bytes {value} requires SQLite WAL mode")]
WalCeilingNonWalBackend { name: String, value: u64 },
#[error(
"backends {first_backend:?} and {second_backend:?} name the same database at {} \
but resolve different WAL ceilings ({first_bytes} and {second_bytes} bytes)",
crate::secret_gate::bounded_masked_log_text(&path.to_string_lossy())
)]
WalCeilingAliasConflict {
first_backend: String,
second_backend: String,
path: PathBuf,
first_bytes: u64,
second_bytes: u64,
},
#[error(
"backend configuration leaves searchable substrate kinds {kinds:?} unserved; \
defined backends: {defined}"
)]
MissingBackendSearchKinds {
kinds: Vec<SubstrateKind>,
defined: String,
},
#[error(
"[packs.{pack}].backend = {backend:?} references an unknown backend; \
defined backends: {defined}"
)]
UnknownPackBackend {
pack: String,
backend: String,
defined: String,
},
#[error(
"[[backends]] entry {name:?}: field `{field}` is not yet supported; \
remove it from the config or wait for a future release that implements it"
)]
UnsupportedBackendField { name: String, field: &'static str },
#[error(
"top-level `db = {value:?}` is not a supported config-file key; \
use `--db` / `KHIVE_DB` to select a single-file database, or \
`[[backends]].path` to declare storage backend topology"
)]
UnsupportedTopLevelDb { value: String },
#[error("[[git_write.allowed]] entry {repo:?}: {reason}")]
InvalidGitWriteEntry { repo: String, reason: String },
#[error("[git_write] {key}: {reason}")]
InvalidGitWriteConfig { key: String, reason: String },
#[error("[exec] {key}: {reason}")]
InvalidExecConfig { key: String, reason: String },
#[error("{entry}: {reason}")]
InvalidTelemetryConfig { entry: String, reason: String },
#[error("[web] {key}: {reason}")]
InvalidWebConfig { key: String, reason: String },
#[error(
"[runtime] blob_hydration_bytes must be between {min} and {max} bytes inclusive; got {value}"
)]
InvalidBlobHydrationBytes { value: u64, min: u64, max: u64 },
#[error("the explicitly selected config file does not exist: {path}")]
ExplicitConfigMissing { path: PathBuf },
#[error("[gate] configuration is not supported by this build")]
UnsupportedGateSection,
#[error(
"[display] timezone {timezone:?} is not a recognized IANA zone name (e.g. \"America/New_York\", \"UTC\")"
)]
InvalidDisplayTimezone { timezone: String },
#[error("{source} (config file: {})", path.display())]
InFile {
path: PathBuf,
#[source]
source: Box<ConfigError>,
},
}
impl ConfigError {
fn in_file(self, path: &Path) -> Self {
match self {
already @ (ConfigError::Parse { .. }
| ConfigError::ExplicitConfigMissing { .. }
| ConfigError::InFile { .. }) => already,
other => ConfigError::InFile {
path: path.to_path_buf(),
source: Box::new(other),
},
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EngineConfig {
pub name: String,
pub model: String,
#[serde(default)]
pub default: bool,
pub fusion_weight: Option<f64>,
pub dims: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ActorConfig {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub mailbox_readers: Vec<String>,
#[serde(default)]
pub visible_namespaces: Option<Vec<String>>,
#[serde(default)]
pub allowed_outbound_namespaces: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GateSectionConfig {
#[serde(default)]
pub granted_actors: Vec<String>,
#[serde(default)]
pub grant_unattributed: bool,
#[serde(default)]
pub deny_writes_for: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BackendKind {
#[default]
Sqlite,
Memory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedWalCeiling {
pub configured_bytes: u64,
pub effective_bytes: u64,
pub source: khive_db::WalCeilingSource,
}
pub fn resolve_wal_ceiling(
backend_field: Option<u64>,
env_value: Option<&str>,
backend_name: &str,
kind: BackendKind,
wal_mode: bool,
read_only: bool,
) -> Result<ResolvedWalCeiling, ConfigError> {
if kind == BackendKind::Memory && backend_field.is_none() {
return Ok(ResolvedWalCeiling {
configured_bytes: 0,
effective_bytes: 0,
source: khive_db::WalCeilingSource::Default,
});
}
let (configured_bytes, source) = if let Some(bytes) = backend_field {
(bytes, khive_db::WalCeilingSource::BackendField)
} else if let Some(raw) = env_value {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(ConfigError::InvalidWalCeilingEnvironment {
value: raw.to_owned(),
});
}
let bytes = raw
.parse::<u64>()
.map_err(|_| ConfigError::InvalidWalCeilingEnvironment {
value: raw.to_owned(),
})?;
(bytes, khive_db::WalCeilingSource::Environment)
} else {
(0, khive_db::WalCeilingSource::Default)
};
if configured_bytes != 0 {
if i64::try_from(configured_bytes).is_err() {
return Err(ConfigError::WalCeilingOffsetOverflow {
name: backend_name.to_owned(),
value: configured_bytes,
});
}
if kind == BackendKind::Memory {
return Err(ConfigError::WalCeilingMemoryBackend {
name: backend_name.to_owned(),
value: configured_bytes,
});
}
if !wal_mode {
return Err(ConfigError::WalCeilingNonWalBackend {
name: backend_name.to_owned(),
value: configured_bytes,
});
}
}
Ok(ResolvedWalCeiling {
configured_bytes,
effective_bytes: if read_only { 0 } else { configured_bytes },
source,
})
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BackendConfig {
pub name: String,
#[serde(default)]
pub kind: BackendKind,
pub path: Option<std::path::PathBuf>,
pub cache_mb: Option<u32>,
pub journal_mode: Option<String>,
#[serde(default)]
pub served_kinds: Option<BTreeSet<SubstrateKind>>,
#[serde(default)]
pub read_only: bool,
pub wal_ceiling_bytes: Option<u64>,
#[serde(default)]
pub disk_reserve_bytes: Option<u64>,
#[serde(default)]
pub disk_guard_deadline_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PackConfig {
pub backend: String,
#[serde(default)]
pub no_embed: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
pub enum BlobConfig {
Fs {
#[serde(default)]
root: Option<String>,
#[serde(default)]
floor_bytes: Option<u64>,
},
S3 {
bucket: String,
region: String,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
allow_http: Option<bool>,
},
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BlobSectionConfig {
#[serde(default)]
pub file_transfers: bool,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct StorageSectionConfig {
#[serde(default)]
pub blob: Option<BlobConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BrainSectionConfig {
#[serde(default)]
pub fleet_readers: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitWriteEntryConfig {
pub repo: String,
pub branches: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitWriteSectionConfig {
#[serde(default)]
pub program: Option<PathBuf>,
#[serde(default)]
pub allowed: Vec<GitWriteEntryConfig>,
#[serde(default)]
pub actors: BTreeMap<String, GitWriteActorConfig>,
#[serde(default)]
pub repositories: BTreeMap<String, GitWriteRepositoryConfig>,
#[serde(default = "default_git_credential_resolver")]
pub credential_resolver: Vec<String>,
#[serde(default)]
pub contract_faults: bool,
#[serde(default)]
pub fault: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct GitWriteRepositoryConfig {
pub remote: String,
pub slug: String,
pub visibility: String,
#[serde(default)]
pub merge_refusals: Vec<String>,
}
impl GitWriteRepositoryConfig {
pub const MERGE_REFUSALS: [&'static str; 2] = ["opener", "last_pusher"];
pub fn refuses_merge_by(&self, entry: &str) -> bool {
self.merge_refusals.iter().any(|listed| listed == entry)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GitWriteActorConfig {
pub name: String,
pub email: String,
pub credential_ref: String,
pub platform_identity: String,
}
fn default_git_credential_resolver() -> Vec<String> {
[
"/usr/bin/security",
"find-generic-password",
"-w",
"-s",
"{ref}",
]
.into_iter()
.map(str::to_string)
.collect()
}
impl Default for GitWriteSectionConfig {
fn default() -> Self {
Self {
program: None,
allowed: Vec::new(),
actors: BTreeMap::new(),
repositories: BTreeMap::new(),
credential_resolver: default_git_credential_resolver(),
contract_faults: false,
fault: None,
}
}
}
impl GitWriteSectionConfig {
pub fn git_program(&self) -> &Path {
self.program.as_deref().unwrap_or_else(|| Path::new("git"))
}
pub fn validate_dev_loop(&self) -> Result<(), ConfigError> {
let invalid = |key: &str, reason: &str| ConfigError::InvalidGitWriteConfig {
key: key.to_string(),
reason: reason.to_string(),
};
if let Some(program) = &self.program {
if !program.is_absolute() {
return Err(invalid("git_write.program", "must be absolute"));
}
let metadata = std::fs::metadata(program).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
invalid("git_write.program", "does not exist")
} else {
invalid("git_write.program", &format!("is not executable: {error}"))
}
})?;
#[cfg(unix)]
let executable = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
};
#[cfg(windows)]
let executable = program
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("exe") || extension.eq_ignore_ascii_case("com")
});
#[cfg(not(any(unix, windows)))]
let executable = false;
if !metadata.is_file() || !executable {
return Err(invalid("git_write.program", "is not executable"));
}
}
if self.contract_faults && !cfg!(feature = "contract-faults") {
tracing::error!(
target: "khive.boot",
"[git_write] contract_faults requires the test-only contract-faults build feature"
);
return Err(invalid(
"contract_faults",
"requires the test-only contract-faults build feature",
));
}
if let Some(fault) = &self.fault {
if !self.contract_faults {
return Err(invalid("fault", "requires contract_faults = true"));
}
let valid = fault.split_once(':').is_some_and(|(verb, point)| {
matches!(verb, "git.push" | "git.pr_merge")
&& matches!(
point,
"reply-lost-after-effect" | "audit-fails-after-effect"
)
});
if !valid {
return Err(invalid("fault", "unsupported contract fault selector"));
}
}
for (path, repository) in &self.repositories {
let key = format!("repositories.{path}.merge_refusals");
let mut seen: Vec<&str> = Vec::new();
for entry in &repository.merge_refusals {
if !GitWriteRepositoryConfig::MERGE_REFUSALS.contains(&entry.as_str()) {
return Err(invalid(&key, "entries must be opener or last_pusher"));
}
if seen.contains(&entry.as_str()) {
return Err(invalid(&key, "entries must not repeat"));
}
seen.push(entry);
}
}
if !cfg!(unix)
&& self.actors.is_empty()
&& self.credential_resolver == default_git_credential_resolver()
{
return Ok(());
}
let argv = &self.credential_resolver;
let Some(program) = argv.first() else {
return Err(invalid("credential_resolver", "argv must not be empty"));
};
let program_path = Path::new(program);
if !program_path.is_absolute() {
return Err(invalid(
"credential_resolver",
"argv[0] must be an absolute path",
));
}
let program_name = program_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(
program_name.trim_end_matches(".exe"),
"sh" | "bash"
| "dash"
| "zsh"
| "ksh"
| "fish"
| "csh"
| "tcsh"
| "cmd"
| "powershell"
| "pwsh"
| "env"
) {
return Err(invalid(
"credential_resolver",
"shell or env launcher is not allowed",
));
}
if argv.iter().any(|arg| arg.chars().any(char::is_control)) {
return Err(invalid(
"credential_resolver",
"argv must not contain control characters",
));
}
if program.contains(['{', '}'])
|| argv[1..]
.iter()
.any(|arg| arg != "{ref}" && arg.contains(['{', '}']))
{
return Err(invalid(
"credential_resolver",
"{ref} must be a complete argument and is the only allowed template",
));
}
if !argv[1..].iter().any(|arg| arg == "{ref}") {
return Err(invalid(
"credential_resolver",
"argv must contain a {ref} argument",
));
}
for (actor, identity) in &self.actors {
if actor.trim().is_empty() || actor.chars().any(char::is_control) {
return Err(invalid(
"actors",
"actor labels must be nonempty and contain no control characters",
));
}
for (field, value) in [
("name", &identity.name),
("email", &identity.email),
("credential_ref", &identity.credential_ref),
("platform_identity", &identity.platform_identity),
] {
if value.trim().is_empty() || value.chars().any(char::is_control) {
return Err(invalid(
&format!("actors.{actor}.{field}"),
"must be nonempty and contain no control characters",
));
}
}
if identity.name.contains(['<', '>']) || identity.email.contains(['<', '>']) {
return Err(invalid(
&format!("actors.{actor}"),
"name and email must not contain Git identity delimiters",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExecLimitsConfig {
#[serde(default)]
pub cpu_seconds: Option<u64>,
#[serde(default)]
pub address_space: Option<u64>,
#[serde(default)]
pub file_size: Option<u64>,
#[serde(default)]
pub nproc: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ExecSectionConfig {
#[serde(default)]
pub root: Option<String>,
#[serde(default)]
pub read_roots: Vec<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub never: Vec<String>,
#[serde(default)]
pub max_output_bytes: Option<u64>,
#[serde(default)]
pub timeout_default_s: Option<f64>,
#[serde(default)]
pub timeout_max_s: Option<f64>,
#[serde(default)]
pub binary_digest_timeout_s: Option<u64>,
#[serde(default)]
pub keep: bool,
#[serde(default)]
pub limits: ExecLimitsConfig,
}
pub const DEFAULT_EXEC_BINARY_DIGEST_TIMEOUT_S: u64 = 10;
pub const MAX_EXEC_BINARY_DIGEST_TIMEOUT_S: u64 = 60;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WebAllowlistEntry {
pub host: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WebCredentialConfig {
pub name: String,
pub env_var: String,
pub hosts: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WebFixtureResult {
pub title: String,
pub url: String,
pub snippet: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
pub enum WebSearchProviderConfig {
Fixture {
name: String,
#[serde(default)]
default: bool,
results: Vec<WebFixtureResult>,
},
Http {
name: String,
#[serde(default)]
default: bool,
url_template: String,
#[serde(default)]
api_key_env: Option<String>,
#[serde(default)]
hosts: Vec<String>,
},
}
impl WebSearchProviderConfig {
pub fn name(&self) -> &str {
match self {
WebSearchProviderConfig::Fixture { name, .. } => name,
WebSearchProviderConfig::Http { name, .. } => name,
}
}
pub fn is_default(&self) -> bool {
match self {
WebSearchProviderConfig::Fixture { default, .. } => *default,
WebSearchProviderConfig::Http { default, .. } => *default,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct WebSectionConfig {
#[serde(default)]
pub timeout_default_s: Option<u64>,
#[serde(default)]
pub timeout_max_s: Option<u64>,
#[serde(default)]
pub max_bytes_default: Option<u64>,
#[serde(default)]
pub max_bytes_max: Option<u64>,
#[serde(default)]
pub search_limit_default: Option<u32>,
#[serde(default)]
pub search_limit_max: Option<u32>,
#[serde(default)]
pub allowlist: Vec<WebAllowlistEntry>,
#[serde(default)]
pub credentials: Vec<WebCredentialConfig>,
#[serde(default)]
pub search_providers: Vec<WebSearchProviderConfig>,
#[serde(default)]
pub read_roots: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WebCeilings {
pub timeout_default_s: u64,
pub timeout_max_s: u64,
pub max_bytes_default: u64,
pub max_bytes_max: u64,
pub search_limit_default: u32,
pub search_limit_max: u32,
}
impl Default for WebCeilings {
fn default() -> Self {
Self {
timeout_default_s: 30,
timeout_max_s: 120,
max_bytes_default: 5 * 1024 * 1024,
max_bytes_max: 50 * 1024 * 1024,
search_limit_default: 10,
search_limit_max: 50,
}
}
}
impl WebSectionConfig {
pub fn resolved_ceilings(&self) -> Result<WebCeilings, ConfigError> {
let defaults = WebCeilings::default();
let bounds = WebCeilings {
timeout_default_s: self.timeout_default_s.unwrap_or(defaults.timeout_default_s),
timeout_max_s: self.timeout_max_s.unwrap_or(defaults.timeout_max_s),
max_bytes_default: self.max_bytes_default.unwrap_or(defaults.max_bytes_default),
max_bytes_max: self.max_bytes_max.unwrap_or(defaults.max_bytes_max),
search_limit_default: self
.search_limit_default
.unwrap_or(defaults.search_limit_default),
search_limit_max: self.search_limit_max.unwrap_or(defaults.search_limit_max),
};
for (key, default, maximum, maximum_key) in [
(
"timeout_default_s",
bounds.timeout_default_s,
bounds.timeout_max_s,
"timeout_max_s",
),
(
"max_bytes_default",
bounds.max_bytes_default,
bounds.max_bytes_max,
"max_bytes_max",
),
(
"search_limit_default",
u64::from(bounds.search_limit_default),
u64::from(bounds.search_limit_max),
"search_limit_max",
),
] {
if default == 0 || default > maximum {
return Err(ConfigError::InvalidWebConfig {
key: key.into(),
reason: format!(
"resolved default must be positive and not exceed {maximum_key}={maximum}"
),
});
}
}
if std::time::Instant::now()
.checked_add(std::time::Duration::from_secs(bounds.timeout_max_s))
.is_none()
{
return Err(ConfigError::InvalidWebConfig {
key: "timeout_max_s".into(),
reason: "cannot be represented as a request deadline".into(),
});
}
Ok(bounds)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.resolved_ceilings()?;
let invalid = |key: &str, reason: &str| ConfigError::InvalidWebConfig {
key: key.to_string(),
reason: reason.to_string(),
};
let mut seen_hosts = std::collections::HashSet::new();
for entry in &self.allowlist {
let normalized = entry.host.trim().trim_end_matches('.').to_ascii_lowercase();
if normalized.is_empty() {
return Err(invalid("allowlist.host", "must not be empty"));
}
if !seen_hosts.insert(normalized) {
return Err(invalid("allowlist.host", "duplicate host entry"));
}
}
let mut seen_credentials = std::collections::HashSet::new();
for credential in &self.credentials {
if credential.name.trim().is_empty() {
return Err(invalid("credentials.name", "must not be empty"));
}
if !seen_credentials.insert(credential.name.clone()) {
return Err(invalid("credentials.name", "duplicate credential name"));
}
if credential.env_var.trim().is_empty() {
return Err(invalid("credentials.env_var", "must not be empty"));
}
if credential.hosts.is_empty() {
return Err(invalid(
"credentials.hosts",
"must name at least one host or suffix",
));
}
}
let mut seen_providers = std::collections::HashSet::new();
let mut default_count = 0;
for provider in &self.search_providers {
let name = provider.name();
if name.trim().is_empty() {
return Err(invalid("search_providers.name", "must not be empty"));
}
if !seen_providers.insert(name.to_string()) {
return Err(invalid("search_providers.name", "duplicate provider name"));
}
if provider.is_default() {
default_count += 1;
}
if let WebSearchProviderConfig::Http {
url_template,
api_key_env,
hosts,
..
} = provider
{
if !url_template.contains("{query}") {
return Err(invalid(
"search_providers.url_template",
"must contain the literal substring {query}",
));
}
if api_key_env.is_some() && hosts.is_empty() {
return Err(invalid(
"search_providers.hosts",
"an api_key_env-bearing provider must name at least one host or suffix",
));
}
}
}
if default_count > 1 {
return Err(invalid(
"search_providers",
"at most one provider may set default = true",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct KhiveConfig {
#[serde(skip)]
pub credentials: Vec<crate::credentials::CredentialConfig>,
#[serde(skip)]
pub visibility_receipts: Option<crate::credentials::VisibilityReceiptConfig>,
#[serde(default)]
pub mounts: Vec<crate::mount_config::MountConfig>,
#[serde(default)]
pub db: Option<String>,
#[serde(default)]
pub engines: Vec<EngineConfig>,
#[serde(default)]
pub actor: ActorConfig,
#[serde(default)]
pub gate: Option<GateSectionConfig>,
#[serde(default)]
pub runtime: RuntimeSectionConfig,
#[serde(default)]
pub backends: Vec<BackendConfig>,
#[serde(default)]
pub packs: std::collections::HashMap<String, PackConfig>,
#[serde(default)]
pub brain: BrainSectionConfig,
#[serde(default)]
pub git_write: GitWriteSectionConfig,
#[serde(default)]
pub blob: BlobSectionConfig,
#[serde(default)]
pub storage: StorageSectionConfig,
#[serde(default)]
pub exec: ExecSectionConfig,
#[serde(default)]
pub telemetry: crate::telemetry_config::TelemetryConfig,
#[serde(default)]
pub display: DisplaySectionConfig,
#[serde(default)]
pub web: WebSectionConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RuntimeSectionConfig {
#[serde(default)]
pub packs: Option<Vec<String>>,
#[serde(default)]
pub brain_profile: Option<String>,
#[serde(default)]
pub default_output_format: Option<OutputFormat>,
#[serde(default)]
pub blob_hydration_bytes: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DisplaySectionConfig {
#[serde(default)]
pub timezone: Option<String>,
}
impl KhiveConfig {
pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
let resolved = match path {
Some(p) => p.to_path_buf(),
None => PathBuf::from(".khive/config.toml"),
};
if !resolved.exists() {
return Ok(None);
}
let diagnostic_path = std::fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
let raw = std::fs::read_to_string(&resolved)
.map_err(|source| ConfigError::from(source).in_file(&diagnostic_path))?;
let mut cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
path: diagnostic_path.clone(),
source,
})?;
crate::credentials::read_tables(&raw, &mut cfg)
.map_err(|error| ConfigError::from(error).in_file(&diagnostic_path))?;
cfg.validate()
.map_err(|error| error.in_file(&diagnostic_path))?;
Ok(Some(cfg))
}
pub fn load_with_home_fallback(
path: Option<&Path>,
db_path: Option<&Path>,
) -> Result<Option<Self>, ConfigError> {
Ok(Self::load_with_home_fallback_and_source(path, db_path)?.map(|(config, _)| config))
}
pub fn load_with_home_fallback_and_source(
path: Option<&Path>,
db_path: Option<&Path>,
) -> Result<Option<(Self, PathBuf)>, ConfigError> {
if let Some(p) = path {
if !p.exists() {
return Err(ConfigError::ExplicitConfigMissing {
path: p.to_path_buf(),
});
}
return Ok(Self::load(Some(p))?.map(|config| (config, Self::diagnostic_config_path(p))));
}
let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let home_root = std::env::var_os("HOME").map(PathBuf::from);
Self::load_with_roots_and_source(&project_root, home_root.as_deref(), db_path)
}
#[cfg(test)]
pub(crate) fn load_with_roots(
project_root: &Path,
home_root: Option<&Path>,
db_path: Option<&Path>,
) -> Result<Option<Self>, ConfigError> {
Ok(
Self::load_with_roots_and_source(project_root, home_root, db_path)?
.map(|(config, _)| config),
)
}
fn load_with_roots_and_source(
project_root: &Path,
home_root: Option<&Path>,
db_path: Option<&Path>,
) -> Result<Option<(Self, PathBuf)>, ConfigError> {
let tier2 = project_root.join("khive.toml");
if tier2.exists() {
return Ok(Self::load(Some(&tier2))?
.map(|config| (config, Self::diagnostic_config_path(&tier2))));
}
let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
if tier3.exists() {
return Ok(Self::load(Some(&tier3))?
.map(|config| (config, Self::diagnostic_config_path(&tier3))));
}
if let Some(home) = home_root {
let tier4 = home.join(".khive/config.toml");
if tier4.exists() {
return Ok(Self::load(Some(&tier4))?
.map(|config| (config, Self::diagnostic_config_path(&tier4))));
}
}
Ok(None)
}
fn diagnostic_config_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
let Some(db_path) = db_path else {
return project_root.join(".khive");
};
let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
if db_path.is_absolute() {
db_path.to_path_buf()
} else {
project_root.join(db_path)
}
});
let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
if db_dir.file_name().is_some_and(|name| name == ".khive") {
db_dir
} else {
db_dir.join(".khive")
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
crate::mount_config::validate_mounts(&self.mounts)?;
self.git_write.validate_dev_loop()?;
self.telemetry.validate()?;
self.web.validate()?;
crate::credentials::CredentialConfig::validate_all(&self.credentials)?;
if let Some(receipts) = &self.visibility_receipts {
receipts.validate(&self.credentials)?;
}
if let Some(value) = self.db.as_deref() {
if !value.is_empty() {
return Err(ConfigError::UnsupportedTopLevelDb {
value: value.to_string(),
});
}
}
if cfg!(target_os = "macos") {
if self.exec.limits.address_space.is_some() {
return Err(ConfigError::InvalidExecConfig {
key: "limits.address_space".to_string(),
reason: "unsupported_on_platform: macOS does not enforce an address-space rlimit per process".to_string(),
});
}
if self.exec.limits.nproc.is_some() {
return Err(ConfigError::InvalidExecConfig {
key: "limits.nproc".to_string(),
reason: "unsupported_on_platform: RLIMIT_NPROC counts every process of the uid, not one run".to_string(),
});
}
}
let default_timeout = self.exec.timeout_default_s.unwrap_or(30.0);
let maximum_timeout = self.exec.timeout_max_s.unwrap_or(600.0);
for (key, value) in [
("timeout_default_s", default_timeout),
("timeout_max_s", maximum_timeout),
] {
let duration = value
.is_finite()
.then(|| std::time::Duration::try_from_secs_f64(value).ok())
.flatten()
.filter(|duration| !duration.is_zero());
if duration
.is_none_or(|duration| std::time::Instant::now().checked_add(duration).is_none())
{
return Err(ConfigError::InvalidExecConfig {
key: key.to_string(),
reason: "must be positive, finite, and representable as a deadline".to_string(),
});
}
}
if default_timeout > maximum_timeout {
return Err(ConfigError::InvalidExecConfig {
key: "timeout_default_s".to_string(),
reason: format!(
"default {default_timeout} exceeds timeout_max_s {maximum_timeout}"
),
});
}
let binary_digest_timeout = self
.exec
.binary_digest_timeout_s
.unwrap_or(DEFAULT_EXEC_BINARY_DIGEST_TIMEOUT_S);
if !(1..=MAX_EXEC_BINARY_DIGEST_TIMEOUT_S).contains(&binary_digest_timeout) {
return Err(ConfigError::InvalidExecConfig {
key: "binary_digest_timeout_s".to_string(),
reason: format!("must be between 1 and {MAX_EXEC_BINARY_DIGEST_TIMEOUT_S} seconds"),
});
}
if let Some(value) = self.runtime.blob_hydration_bytes {
let min = khive_storage::MAX_BLOB_WHOLE_BYTES;
let max = tokio::sync::Semaphore::MAX_PERMITS as u64;
if value < min || value > max {
return Err(ConfigError::InvalidBlobHydrationBytes { value, min, max });
}
}
if let Some(id) = self.actor.id.as_deref() {
if id.is_empty() {
return Err(ConfigError::InvalidActorId {
id: id.to_string(),
reason: "actor.id must not be empty; remove the key or provide a value"
.to_string(),
});
}
Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
id: id.to_string(),
reason: e.to_string(),
})?;
}
self.actor
.mailbox_gate(std::sync::Arc::new(khive_gate::AllowAllGate))
.map_err(|error| ConfigError::InvalidMailboxReaders {
reason: error.to_string(),
})?;
if let Some(ref vis) = self.actor.visible_namespaces {
for ns_str in vis {
if ns_str.is_empty() {
return Err(ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: "visible_namespaces entries must not be empty".to_string(),
});
}
Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: format!("invalid visible namespace: {e}"),
})?;
}
}
if let Some(gate) = &self.gate {
khive_gate::CallerEnrollmentGate::validate_write_denials(&gate.deny_writes_for)
.map_err(|error| ConfigError::InvalidWriteDenyPatterns {
reason: error.to_string(),
})?;
for id in &gate.granted_actors {
if id.is_empty() {
return Err(ConfigError::InvalidGrantedActorId {
id: id.clone(),
reason: "actor ids must not be empty".to_string(),
});
}
Namespace::parse(id).map_err(|error| ConfigError::InvalidGrantedActorId {
id: id.clone(),
reason: error.to_string(),
})?;
}
}
for ns_str in &self.actor.allowed_outbound_namespaces {
if ns_str.is_empty() {
return Err(ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
});
}
Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
})?;
}
if !self.backends.is_empty() {
let mut seen_backends = std::collections::HashSet::new();
for backend in &self.backends {
BackendId::parse(&backend.name).map_err(|error| {
ConfigError::InvalidBackendName {
name: backend.name.clone(),
reason: error.to_string(),
}
})?;
if backend
.served_kinds
.as_ref()
.is_some_and(BTreeSet::is_empty)
{
return Err(ConfigError::EmptyBackendServedKinds {
name: backend.name.clone(),
});
}
if !seen_backends.insert(backend.name.clone()) {
return Err(ConfigError::DuplicateBackendName {
name: backend.name.clone(),
});
}
if backend.wal_ceiling_bytes.is_some() {
resolve_wal_ceiling(
backend.wal_ceiling_bytes,
None,
&backend.name,
backend.kind.clone(),
backend
.journal_mode
.as_deref()
.is_none_or(|mode| mode.eq_ignore_ascii_case("wal")),
backend.read_only,
)?;
}
backend.resolve_disk_guard(&khive_db::DiskGuardEnvironment::default())?;
if backend.cache_mb.is_some() {
return Err(ConfigError::UnsupportedBackendField {
name: backend.name.clone(),
field: "cache_mb",
});
}
if backend.journal_mode.is_some() {
return Err(ConfigError::UnsupportedBackendField {
name: backend.name.clone(),
field: "journal_mode",
});
}
}
}
let defined: Vec<&str> = if self.backends.is_empty() {
vec![BackendId::MAIN]
} else {
self.backends.iter().map(|b| b.name.as_str()).collect()
};
for (pack_name, pack_cfg) in &self.packs {
if !defined.contains(&pack_cfg.backend.as_str()) {
return Err(ConfigError::UnknownPackBackend {
pack: pack_name.clone(),
backend: pack_cfg.backend.clone(),
defined: defined.join(", "),
});
}
}
if !self.backends.is_empty() {
let missing: Vec<_> = [SubstrateKind::Note, SubstrateKind::Entity]
.into_iter()
.filter(|kind| {
!self.backends.iter().any(|backend| {
backend
.served_kinds
.as_ref()
.is_none_or(|served| served.contains(kind))
})
})
.collect();
if !missing.is_empty() {
return Err(ConfigError::MissingBackendSearchKinds {
kinds: missing,
defined: defined.join(", "),
});
}
}
if let Some(tz) = self.display.timezone.as_deref() {
if tz.trim().is_empty() || tz.parse::<chrono_tz::Tz>().is_err() {
return Err(ConfigError::InvalidDisplayTimezone {
timezone: tz.to_string(),
});
}
}
for entry in &self.git_write.allowed {
if entry.repo.trim().is_empty() {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "repo must not be empty".to_string(),
});
}
if !Path::new(&entry.repo).is_absolute() {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "repo must be an absolute path".to_string(),
});
}
if entry.branches.is_empty() {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "branches must not be empty".to_string(),
});
}
if entry.branches.iter().any(|b| b.trim().is_empty()) {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "branches entries must not be empty".to_string(),
});
}
if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: format!(
"branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
),
});
}
}
if self.engines.is_empty() {
return Ok(());
}
let mut seen_names = std::collections::HashSet::new();
for engine in &self.engines {
if !seen_names.insert(engine.name.clone()) {
return Err(ConfigError::DuplicateName {
name: engine.name.clone(),
});
}
if parse_embedding_model_alias(&engine.model).is_none() {
return Err(ConfigError::UnknownModel {
name: engine.name.clone(),
model: engine.model.clone(),
});
}
}
let default_count = self.engines.iter().filter(|e| e.default).count();
if default_count != 1 {
return Err(ConfigError::DefaultCount {
found: default_count,
});
}
for engine in &self.engines {
if let Some(w) = engine.fusion_weight {
if !w.is_finite() || w <= 0.0 {
return Err(ConfigError::InvalidFusionWeight {
name: engine.name.clone(),
value: w,
});
}
}
}
if let Some(engine) = self
.engines
.iter()
.find(|engine| engine.fusion_weight.is_some())
{
return Err(ConfigError::UnsupportedFusionWeight {
name: engine.name.clone(),
});
}
Ok(())
}
pub fn default_engine(&self) -> Option<&EngineConfig> {
self.engines.iter().find(|e| e.default)
}
}
pub fn config_from_env() -> KhiveConfig {
let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
.ok()
.filter(|s| !s.trim().is_empty());
let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
.ok()
.unwrap_or_default();
let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
.into_iter()
.filter(|s| !s.is_empty())
.collect();
if primary_model.is_none() && additional.is_empty() {
return KhiveConfig::default();
}
tracing::info!(
"using env-var embedding config; consider migrating to .khive/config.toml in your project root"
);
config_from_env_parts(primary_model, additional)
}
fn config_from_env_parts(primary_model: Option<String>, additional: Vec<String>) -> KhiveConfig {
let mut engines = Vec::new();
let primary =
primary_model.unwrap_or_else(|| lattice_embed::EmbeddingModel::AllMiniLmL6V2.to_string());
engines.push(EngineConfig {
name: "default".to_string(),
model: primary.clone(),
default: true,
fusion_weight: None,
dims: None,
});
for (i, model) in additional.into_iter().enumerate() {
if model.eq_ignore_ascii_case(&primary) {
continue;
}
engines.push(EngineConfig {
name: format!("engine-{}", i + 1),
model,
default: false,
fusion_weight: None,
dims: None,
});
}
KhiveConfig {
engines,
..KhiveConfig::default()
}
}
#[cfg(test)]
#[path = "engine_config_tests.rs"]
mod tests;