khive_runtime/engine_config.rs
1//! TOML-based embedding engine configuration for khive.
2//!
3//! Loads `.khive/config.toml` (or `--config` / `KHIVE_CONFIG`) and exposes an
4//! `[[engines]]` array for arbitrary-N embedding engine registration. Falls back
5//! to `KHIVE_EMBEDDING_MODEL` env vars when no config file is present.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::path::{Path, PathBuf};
9
10use khive_types::{namespace::Namespace, SubstrateKind};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::{
15 config::{parse_embedding_model_alias, BackendId},
16 presentation::OutputFormat,
17};
18
19#[path = "engine_config_backend_disk_guard.rs"]
20mod backend_disk_guard;
21
22// ---- Error type ----
23
24/// Errors produced while loading or validating a `KhiveConfig`.
25#[derive(Debug, Error)]
26pub enum ConfigError {
27 #[error(transparent)]
28 Credential(#[from] crate::credentials::CredentialError),
29
30 #[error("mount configuration: {reason}")]
31 InvalidMountConfig { reason: String },
32
33 #[error("config file I/O: {0}")]
34 Io(#[from] std::io::Error),
35
36 #[error("config TOML parse error in {path}: {source}")]
37 Parse {
38 path: PathBuf,
39 #[source]
40 source: toml::de::Error,
41 },
42
43 #[error("exactly one engine must be marked `default = true`; found {found}")]
44 DefaultCount { found: usize },
45
46 #[error("duplicate engine name: {name:?}")]
47 DuplicateName { name: String },
48
49 #[error(
50 "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
51 )]
52 UnknownModel { name: String, model: String },
53
54 #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
55 InvalidFusionWeight { name: String, value: f64 },
56
57 #[error(
58 "engine {name:?}: fusion_weight is not applied by current retrieval; \
59 remove it until weighted multi-engine fusion is wired"
60 )]
61 UnsupportedFusionWeight { name: String },
62
63 #[error("actor.id {id:?} is not a valid namespace: {reason}")]
64 InvalidActorId { id: String, reason: String },
65
66 #[error("[actor].mailbox_readers: {reason}")]
67 InvalidMailboxReaders { reason: String },
68
69 #[error("[gate].granted_actors entry {id:?} is not a valid actor id: {reason}")]
70 InvalidGrantedActorId { id: String, reason: String },
71 #[error("[gate].deny_writes_for is invalid: {reason}")]
72 InvalidWriteDenyPatterns { reason: String },
73
74 #[error("duplicate backend name: {name:?}")]
75 DuplicateBackendName { name: String },
76
77 #[error("invalid backend name {name:?}: {reason}")]
78 InvalidBackendName { name: String, reason: String },
79
80 #[error("backend {name:?}: `served_kinds` must not be empty when declared")]
81 EmptyBackendServedKinds { name: String },
82
83 #[error("backend {name:?}: invalid disk guard configuration: {reason}")]
84 InvalidBackendDiskGuard { name: String, reason: String },
85
86 #[error(
87 "backends {first_backend:?} and {second_backend:?} name the same database but resolve \
88 different disk reserve/deadline policies"
89 )]
90 DiskGuardAliasConflict {
91 first_backend: String,
92 second_backend: String,
93 },
94
95 #[error("KHIVE_SQLITE_WAL_CEILING_BYTES must be an unsigned decimal byte count")]
96 InvalidWalCeilingEnvironment { value: String },
97
98 #[error(
99 "backend {name:?}: wal_ceiling_bytes {value} exceeds supported SQLite offset arithmetic"
100 )]
101 WalCeilingOffsetOverflow { name: String, value: u64 },
102
103 #[error(
104 "backend {name:?}: nonzero wal_ceiling_bytes {value} requires a file-backed SQLite backend"
105 )]
106 WalCeilingMemoryBackend { name: String, value: u64 },
107
108 #[error("backend {name:?}: nonzero wal_ceiling_bytes {value} requires SQLite WAL mode")]
109 WalCeilingNonWalBackend { name: String, value: u64 },
110
111 #[error(
112 "backends {first_backend:?} and {second_backend:?} name the same database at {} \
113 but resolve different WAL ceilings ({first_bytes} and {second_bytes} bytes)",
114 crate::secret_gate::bounded_masked_log_text(&path.to_string_lossy())
115 )]
116 WalCeilingAliasConflict {
117 first_backend: String,
118 second_backend: String,
119 path: PathBuf,
120 first_bytes: u64,
121 second_bytes: u64,
122 },
123
124 #[error(
125 "backend configuration leaves searchable substrate kinds {kinds:?} unserved; \
126 defined backends: {defined}"
127 )]
128 MissingBackendSearchKinds {
129 kinds: Vec<SubstrateKind>,
130 defined: String,
131 },
132
133 #[error(
134 "[packs.{pack}].backend = {backend:?} references an unknown backend; \
135 defined backends: {defined}"
136 )]
137 UnknownPackBackend {
138 pack: String,
139 backend: String,
140 defined: String,
141 },
142
143 #[error(
144 "[[backends]] entry {name:?}: field `{field}` is not yet supported; \
145 remove it from the config or wait for a future release that implements it"
146 )]
147 UnsupportedBackendField { name: String, field: &'static str },
148
149 #[error(
150 "top-level `db = {value:?}` is not a supported config-file key; \
151 use `--db` / `KHIVE_DB` to select a single-file database, or \
152 `[[backends]].path` to declare storage backend topology"
153 )]
154 UnsupportedTopLevelDb { value: String },
155
156 #[error("[[git_write.allowed]] entry {repo:?}: {reason}")]
157 InvalidGitWriteEntry { repo: String, reason: String },
158
159 #[error("[git_write] {key}: {reason}")]
160 InvalidGitWriteConfig { key: String, reason: String },
161
162 #[error("[exec] {key}: {reason}")]
163 InvalidExecConfig { key: String, reason: String },
164
165 #[error("{entry}: {reason}")]
166 InvalidTelemetryConfig { entry: String, reason: String },
167
168 #[error("[web] {key}: {reason}")]
169 InvalidWebConfig { key: String, reason: String },
170
171 #[error(
172 "[runtime] blob_hydration_bytes must be between {min} and {max} bytes inclusive; got {value}"
173 )]
174 InvalidBlobHydrationBytes { value: u64, min: u64, max: u64 },
175
176 #[error("the explicitly selected config file does not exist: {path}")]
177 ExplicitConfigMissing { path: PathBuf },
178
179 /// Retained for source compatibility with callers that matched the
180 /// fail-loud behavior of older builds. Supported `[gate]` sections no
181 /// longer produce this error.
182 #[error("[gate] configuration is not supported by this build")]
183 UnsupportedGateSection,
184
185 #[error(
186 "[display] timezone {timezone:?} is not a recognized IANA zone name (e.g. \"America/New_York\", \"UTC\")"
187 )]
188 InvalidDisplayTimezone { timezone: String },
189
190 /// Loader-context wrapper attaching the config file the error came from.
191 ///
192 /// Added as a wrapping variant, rather than reshaping the existing
193 /// variants, so every existing constructor, field access, and the
194 /// `From<std::io::Error>` conversion survive unchanged. The enum is not
195 /// `#[non_exhaustive]`, so an exhaustive `match` on `ConfigError` must
196 /// still add an arm for this variant — either matching `InFile` and
197 /// recursing into `source`, or a wildcard. `Parse` already carries its
198 /// path and is never wrapped.
199 #[error("{source} (config file: {})", path.display())]
200 InFile {
201 path: PathBuf,
202 #[source]
203 source: Box<ConfigError>,
204 },
205}
206
207impl ConfigError {
208 /// Attach the loading config file's path unless the error already names
209 /// one (`Parse`, `ExplicitConfigMissing`) or is already wrapped.
210 fn in_file(self, path: &Path) -> Self {
211 match self {
212 already @ (ConfigError::Parse { .. }
213 | ConfigError::ExplicitConfigMissing { .. }
214 | ConfigError::InFile { .. }) => already,
215 other => ConfigError::InFile {
216 path: path.to_path_buf(),
217 source: Box::new(other),
218 },
219 }
220 }
221}
222
223// ---- Config structs ----
224
225/// Configuration for a single embedding engine.
226#[derive(Debug, Clone, Deserialize)]
227pub struct EngineConfig {
228 /// Logical name used to reference this engine in logs and fusion.
229 pub name: String,
230
231 /// Lattice-embed model name (e.g. `"all-minilm-l6-v2"`).
232 ///
233 /// Must be parseable via `lattice_embed::EmbeddingModel::from_str` (or a
234 /// recognised short alias handled by `parse_embedding_model_alias`).
235 pub model: String,
236
237 /// When `true`, this engine's model becomes the primary (`RuntimeConfig::embedding_model`).
238 /// Exactly one engine in the list must set this. If absent, defaults to `false`.
239 #[serde(default)]
240 pub default: bool,
241
242 /// Reserved RRF fusion weight for future weighted multi-engine fusion.
243 ///
244 /// Current retrieval does not consume this field. Config loading rejects
245 /// any explicit value rather than silently treating it as applied. Leave
246 /// it unset until per-engine weighted fusion is implemented. The loader
247 /// still distinguishes invalid (non-finite or non-positive) values from
248 /// valid but unsupported ones.
249 pub fusion_weight: Option<f64>,
250
251 /// Expected output dimensionality (optional sanity check).
252 ///
253 /// Not used at runtime — dimensions are authoritative from
254 /// `EmbeddingModel::dimensions()`. Present so operators can document the
255 /// expected shape alongside the model name.
256 pub dims: Option<u32>,
257}
258
259/// Actor configuration — the default namespace / identity for this khive instance.
260///
261/// Corresponds to the `[actor]` TOML section. `id` is used as the
262/// `default_namespace` for gate/attribution policy input. OSS dispatch pins
263/// writes to the shared `local` namespace regardless of this value (ADR-007
264/// Rev 4 Rule 0); cloud deployments derive the namespace from an authenticated
265/// `NamespaceToken` instead.
266///
267/// ```toml
268/// [actor]
269/// id = "lambda:leo" # attribution identity (required)
270/// display_name = "example actor" # human label (optional)
271/// visible_namespaces = ["lambda:khive", "local"] # widens default read scope (ADR-007 Rev 4 Rule 3b)
272/// ```
273///
274/// `visible_namespaces` is consumed by OSS dispatch to widen the DEFAULT
275/// multi-record read scope to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4
276/// Rule 3b). Writes remain pinned to `'local'`. An explicit `namespace=` request
277/// param is a precise single-namespace escape and is not widened. A cloud gate
278/// may also consult this list as policy input at its own layer.
279///
280/// The table is closed. Both keys above are authorization input, so a misspelled
281/// key fails startup instead of silently applying its default, and a `[gate]` key
282/// written here is reported rather than discarded.
283#[derive(Debug, Clone, Deserialize, Default)]
284#[serde(deny_unknown_fields)]
285pub struct ActorConfig {
286 /// Namespace identifier used as the default actor for all operations.
287 ///
288 /// Must be a valid `Namespace` string (e.g. `"local"`, `"lambda:khive"`).
289 /// Defaults to `"local"` when absent — backward-compatible with pre-actor
290 /// deployments.
291 #[serde(default)]
292 pub id: Option<String>,
293
294 /// Optional human-readable label for this actor. Not used by the runtime;
295 /// surfaced in introspection and log output only.
296 #[serde(default)]
297 pub display_name: Option<String>,
298
299 /// Exact actor labels permitted to inspect this explicit actor's mailbox.
300 ///
301 /// A nonempty list requires an explicit non-local `id`. Labels are bounded
302 /// to 255 bytes, nonblank, and contain no control characters; `local` is
303 /// forbidden. At most 256 entries are accepted before deduplication. This
304 /// is trusted serving-host policy, never inferred from environment identity
305 /// or supplied by a request. Changes take effect in a new server epoch.
306 #[serde(default)]
307 pub mailbox_readers: Vec<String>,
308
309 /// Additional namespaces that widen the DEFAULT multi-record read scope
310 /// to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4 Rule 3b). Each string
311 /// must be a valid `Namespace`. Writes remain pinned to `'local'`. An
312 /// explicit `namespace=` request param is a precise escape and is not widened
313 /// by this list. A cloud gate may also consult it as policy input.
314 #[serde(default)]
315 pub visible_namespaces: Option<Vec<String>>,
316
317 /// Namespaces this actor's comm.send/reply may deliver messages INTO
318 /// (outbound, sender-side). Empty by default — cross-namespace delivery
319 /// denied unless explicitly declared. The comm handler uses an ordinary
320 /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
321 /// the token itself is NOT type-enforced write-only. The recipient-side
322 /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
323 /// a future cloud-path authorization ADR (not yet written).
324 ///
325 /// Each entry must be a valid `Namespace` string; validated at
326 /// config-load time. An empty list preserves the prior deny-all behavior
327 /// for any actor that does not add this field.
328 #[serde(default)]
329 pub allowed_outbound_namespaces: Vec<String>,
330}
331
332/// Built-in caller-enrollment policy configured by `[gate]`.
333///
334/// The table is intentionally closed: misspelled or future keys fail startup
335/// instead of being silently ignored at an authorization boundary. Presence
336/// installs [`khive_gate::CallerEnrollmentGate`]; absence preserves the gate
337/// already supplied in the base [`crate::RuntimeConfig`].
338#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
339#[serde(deny_unknown_fields)]
340pub struct GateSectionConfig {
341 /// Exact resolved actor ids permitted to dispatch requests.
342 #[serde(default)]
343 pub granted_actors: Vec<String>,
344
345 /// Whether the implicit anonymous/local caller is admitted.
346 #[serde(default)]
347 pub grant_unattributed: bool,
348
349 /// Whole actor-ID patterns denying all but explicitly reviewed reads.
350 /// Case-sensitive; only `*` is a wildcard. Does not enroll a caller.
351 #[serde(default)]
352 pub deny_writes_for: Vec<String>,
353}
354
355// ---- Per-pack backend config (ADR-028) ----
356
357/// Storage backend kind.
358#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
359#[serde(rename_all = "lowercase")]
360pub enum BackendKind {
361 /// SQLite file-backed database (default).
362 #[default]
363 Sqlite,
364 /// In-memory database — for testing only; state is lost on restart.
365 Memory,
366}
367
368/// The configured WAL ceiling and the writer policy actually enforced by a backend.
369///
370/// A read-only SQLite backend retains its configured value for reporting but
371/// has no writer policy, so `effective_bytes` is zero.
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub struct ResolvedWalCeiling {
374 /// Selected field, environment, or default value before read-only handling.
375 pub configured_bytes: u64,
376 /// Writer-enforced value; zero for a read-only backend.
377 pub effective_bytes: u64,
378 /// Origin of `configured_bytes`.
379 pub source: khive_db::WalCeilingSource,
380}
381
382/// Resolve the ceiling with backend-field precedence over the environment.
383///
384/// `env_value` is a construction-time snapshot supplied by the host. This
385/// function never reads process environment, so forwarding and backend opening
386/// can validate the same policy even when the environment later changes.
387pub fn resolve_wal_ceiling(
388 backend_field: Option<u64>,
389 env_value: Option<&str>,
390 backend_name: &str,
391 kind: BackendKind,
392 wal_mode: bool,
393 read_only: bool,
394) -> Result<ResolvedWalCeiling, ConfigError> {
395 if kind == BackendKind::Memory && backend_field.is_none() {
396 return Ok(ResolvedWalCeiling {
397 configured_bytes: 0,
398 effective_bytes: 0,
399 source: khive_db::WalCeilingSource::Default,
400 });
401 }
402 let (configured_bytes, source) = if let Some(bytes) = backend_field {
403 (bytes, khive_db::WalCeilingSource::BackendField)
404 } else if let Some(raw) = env_value {
405 if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
406 return Err(ConfigError::InvalidWalCeilingEnvironment {
407 value: raw.to_owned(),
408 });
409 }
410 let bytes = raw
411 .parse::<u64>()
412 .map_err(|_| ConfigError::InvalidWalCeilingEnvironment {
413 value: raw.to_owned(),
414 })?;
415 (bytes, khive_db::WalCeilingSource::Environment)
416 } else {
417 (0, khive_db::WalCeilingSource::Default)
418 };
419
420 if configured_bytes != 0 {
421 if i64::try_from(configured_bytes).is_err() {
422 return Err(ConfigError::WalCeilingOffsetOverflow {
423 name: backend_name.to_owned(),
424 value: configured_bytes,
425 });
426 }
427 if kind == BackendKind::Memory {
428 return Err(ConfigError::WalCeilingMemoryBackend {
429 name: backend_name.to_owned(),
430 value: configured_bytes,
431 });
432 }
433 if !wal_mode {
434 return Err(ConfigError::WalCeilingNonWalBackend {
435 name: backend_name.to_owned(),
436 value: configured_bytes,
437 });
438 }
439 }
440
441 Ok(ResolvedWalCeiling {
442 configured_bytes,
443 effective_bytes: if read_only { 0 } else { configured_bytes },
444 source,
445 })
446}
447
448/// Configuration for a named storage backend.
449///
450/// Corresponds to a `[[backends]]` entry in `khive.toml`.
451/// When no `[[backends]]` section is present, a single implicit `main` backend
452/// is synthesised from the existing `--db` / `KHIVE_DB` / default-path resolution.
453/// All packs fall back to `main` when their name is absent from `[packs]`.
454/// `cache_mb` and `journal_mode` are parsed but rejected during validation
455/// because per-backend tuning is not implemented.
456///
457/// ```toml
458/// [[backends]]
459/// name = "main"
460/// kind = "sqlite"
461/// path = "~/.khive/khive.db"
462/// read_only = false
463/// ```
464#[derive(Debug, Clone, Deserialize)]
465#[serde(deny_unknown_fields)]
466pub struct BackendConfig {
467 /// Unique backend name. Referenced by `[packs.<name>].backend`.
468 pub name: String,
469 /// Storage backend kind. Defaults to `sqlite`.
470 #[serde(default)]
471 pub kind: BackendKind,
472 /// Filesystem path for `sqlite` kind. Tilde is expanded to `$HOME`.
473 /// `None` for `memory` kind (path is ignored when present).
474 pub path: Option<std::path::PathBuf>,
475 /// SQLite page-cache size in MiB. Parsed but rejected as unsupported.
476 pub cache_mb: Option<u32>,
477 /// SQLite journal mode (e.g. `"wal"`). Parsed but rejected as unsupported.
478 pub journal_mode: Option<String>,
479 /// Substrate kinds this backend serves.
480 ///
481 /// Omission preserves conservative fan-out to this backend. An explicit
482 /// declaration is closed over [`SubstrateKind`] and must not be empty.
483 /// The backend set must cover both `note` and `entity` search.
484 #[serde(default)]
485 pub served_kinds: Option<BTreeSet<SubstrateKind>>,
486 /// Open the backend read-only. Defaults to `false`.
487 #[serde(default)]
488 pub read_only: bool,
489 /// WAL extent ceiling in bytes. `None` inherits the environment setting;
490 /// zero disables the ceiling. Read-only backends retain the configured
491 /// value for reporting but enforce no writer policy.
492 pub wal_ceiling_bytes: Option<u64>,
493 /// SQLite disk reserve, in bytes. Zero explicitly disables the floor.
494 #[serde(default)]
495 pub disk_reserve_bytes: Option<u64>,
496 /// Volume-guard acquisition deadline, in milliseconds (100..=10000).
497 #[serde(default)]
498 pub disk_guard_deadline_ms: Option<u64>,
499}
500
501/// Per-pack backend assignment.
502///
503/// Corresponds to a `[packs.<pack-name>]` entry in `khive.toml`.
504/// Packs whose name is absent from `[packs]` fall back to the `main` backend.
505///
506/// ```toml
507/// [packs.knowledge]
508/// backend = "knowledge"
509///
510/// [packs.comm]
511/// backend = "comm"
512/// no_embed = true
513/// ```
514#[derive(Debug, Clone, Deserialize)]
515pub struct PackConfig {
516 /// Backend name this pack is assigned to. Must match a `[[backends]].name`,
517 /// or `main` when no backends are declared.
518 pub backend: String,
519 /// Disable vector embedding for this pack's runtime: rows it writes get
520 /// FTS and metadata only, no `vec_*` rows and no ANN participation. The
521 /// opt-out covers pack-owned writes on the pack's own backend; it does
522 /// NOT cover `core()`-routed concept writes, which embed with the MAIN
523 /// runtime's embedders (the boot path wires them in via
524 /// `with_core_embedders_from`) so the shared graph stays uniformly
525 /// searchable. Fits packs whose own rows are structural rather than
526 /// retrieval targets (e.g. comm). Effective in multi-backend boot, where
527 /// each pack gets its own runtime. Defaults to `false`.
528 #[serde(default)]
529 pub no_embed: bool,
530}
531
532// ---- Blob store config (ADR-111 Amendment 2) ----
533
534/// `[storage.blob]` section: a closed `backend = "fs" | "s3"` selector.
535///
536/// Internally tagged on `backend` with `deny_unknown_fields`: an unknown
537/// top-level key, a field that belongs to the other backend variant (e.g.
538/// `bucket` under `backend = "fs"`), or an S3 credential field (never
539/// accepted in TOML -- ADR-111 Amendment 2 reads credentials from the
540/// process environment only) are all rejected at config-load time by the
541/// same mechanism, since each variant only declares its own fields.
542///
543/// ```toml
544/// [storage.blob]
545/// backend = "fs"
546/// root = "/var/lib/khive/blobs"
547/// floor_bytes = 100000000000
548/// ```
549///
550/// ```toml
551/// [storage.blob]
552/// backend = "s3"
553/// bucket = "khive-blobs"
554/// region = "us-east-1"
555/// endpoint = "https://objects.example.invalid"
556/// prefix = "blobs"
557/// ```
558#[derive(Debug, Clone, Deserialize)]
559#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
560pub enum BlobConfig {
561 /// Filesystem-backed blob storage (`FsBlobStore`). Root resolution is
562 /// unchanged from khive#292: `KHIVE_BLOB_ROOT` env var, then this
563 /// `root`, then `<db_dir>/blobs`.
564 Fs {
565 #[serde(default)]
566 root: Option<String>,
567 #[serde(default)]
568 floor_bytes: Option<u64>,
569 },
570 /// S3-compatible blob storage (`S3BlobStore`). `KHIVE_BLOB_ROOT` has no
571 /// effect for this backend. Credentials always come from
572 /// `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN` in the
573 /// process environment, never from this section.
574 S3 {
575 bucket: String,
576 region: String,
577 #[serde(default)]
578 endpoint: Option<String>,
579 #[serde(default)]
580 prefix: Option<String>,
581 #[serde(default)]
582 allow_http: Option<bool>,
583 },
584}
585
586/// `[blob]` pack policy, separate from the `[storage.blob]` backend selector.
587#[derive(Debug, Clone, Deserialize, Default)]
588#[serde(deny_unknown_fields)]
589pub struct BlobSectionConfig {
590 /// Permit server-local file transfers. Absent means disabled.
591 #[serde(default)]
592 pub file_transfers: bool,
593}
594
595/// `[storage]` section in `khive.toml`. Holds storage-layer config not
596/// already covered by `[[backends]]` (ADR-028). Unknown fields are rejected
597/// so an unsupported database selector cannot silently select the default store.
598#[derive(Debug, Clone, Deserialize, Default)]
599#[serde(deny_unknown_fields)]
600pub struct StorageSectionConfig {
601 /// Blob store backend selector (ADR-111 Amendment 2). Absent means
602 /// `FsBlobStore` at the existing root-resolution precedence, unchanged
603 /// from khive#292 -- existing configurations keep behaving exactly as
604 /// they did before this section existed.
605 #[serde(default)]
606 pub blob: Option<BlobConfig>,
607}
608
609/// `[brain]` read policy resolved by the serving process.
610#[derive(Debug, Clone, Deserialize, Serialize, Default)]
611#[serde(deny_unknown_fields)]
612pub struct BrainSectionConfig {
613 /// Actor ids permitted to request fleet-wide `brain.event_counts` reads.
614 #[serde(default)]
615 pub fleet_readers: Vec<String>,
616}
617
618// ---- git-write policy (ADR-108 Amendment) ----
619
620/// One `[[git_write.allowed]]` entry: a repo this operator has declared
621/// trusted for khive-mediated git writes, plus the branches on it a write
622/// verb (`git.commit`/`git.branch`/`git.update_ref`/`git.push`) may target.
623///
624/// ```toml
625/// [[git_write.allowed]]
626/// repo = "/abs/path/repo"
627/// branches = ["feat/*", "fix/*"]
628/// ```
629#[derive(Debug, Clone, Deserialize, Serialize)]
630pub struct GitWriteEntryConfig {
631 /// Absolute local path to the allowlisted repository.
632 pub repo: String,
633 /// Non-empty list of exact branch names or single-`*`-wildcard globs
634 /// this repo entry permits writes against.
635 pub branches: Vec<String>,
636}
637
638/// `[git_write]` section — the closed repo/branch allowlist consulted by
639/// `khive-pack-git`'s write verbs at the handler level (ADR-108 Amendment),
640/// independent of Gate policy. Absent or empty `allowed` is the fail-closed
641/// default: the write verbs report themselves unavailable rather than
642/// defaulting open.
643///
644/// ```toml
645/// [[git_write.allowed]]
646/// repo = "/abs/path/repo"
647/// branches = ["feat/*", "fix/*"]
648/// ```
649#[derive(Debug, Clone, Deserialize, Serialize)]
650pub struct GitWriteSectionConfig {
651 /// Absolute git executable override; absent preserves PATH resolution.
652 #[serde(default)]
653 pub program: Option<PathBuf>,
654 #[serde(default)]
655 pub allowed: Vec<GitWriteEntryConfig>,
656 #[serde(default)]
657 pub actors: BTreeMap<String, GitWriteActorConfig>,
658 #[serde(default)]
659 pub repositories: BTreeMap<String, GitWriteRepositoryConfig>,
660 #[serde(default = "default_git_credential_resolver")]
661 pub credential_resolver: Vec<String>,
662 #[serde(default)]
663 pub contract_faults: bool,
664 #[serde(default)]
665 pub fault: Option<String>,
666}
667
668#[derive(Debug, Clone, Deserialize, Serialize)]
669#[serde(deny_unknown_fields)]
670pub struct GitWriteRepositoryConfig {
671 /// HTTPS platform remote, or an absolute path / file:/// URL with an empty slug.
672 pub remote: String,
673 /// owner/name for HTTPS; empty explicitly opts into credential-free local pushes.
674 pub slug: String,
675 pub visibility: String,
676 /// Merge dispatch refusals for this repository (ADR-182 Amendment 7):
677 /// `opener` refuses a `git.pr_merge` dispatched by the account or actor
678 /// that opened the pull request; `last_pusher` refuses one dispatched by
679 /// the login on the newest push receipt for `expected_head`. Empty (the
680 /// default) refuses neither.
681 #[serde(default)]
682 pub merge_refusals: Vec<String>,
683}
684
685impl GitWriteRepositoryConfig {
686 pub const MERGE_REFUSALS: [&'static str; 2] = ["opener", "last_pusher"];
687
688 /// Whether this repository row lists the named merge refusal.
689 pub fn refuses_merge_by(&self, entry: &str) -> bool {
690 self.merge_refusals.iter().any(|listed| listed == entry)
691 }
692}
693
694#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
695#[serde(deny_unknown_fields)]
696pub struct GitWriteActorConfig {
697 pub name: String,
698 pub email: String,
699 pub credential_ref: String,
700 pub platform_identity: String,
701}
702
703fn default_git_credential_resolver() -> Vec<String> {
704 [
705 "/usr/bin/security",
706 "find-generic-password",
707 "-w",
708 "-s",
709 "{ref}",
710 ]
711 .into_iter()
712 .map(str::to_string)
713 .collect()
714}
715
716impl Default for GitWriteSectionConfig {
717 fn default() -> Self {
718 Self {
719 program: None,
720 allowed: Vec::new(),
721 actors: BTreeMap::new(),
722 repositories: BTreeMap::new(),
723 credential_resolver: default_git_credential_resolver(),
724 contract_faults: false,
725 fault: None,
726 }
727 }
728}
729
730impl GitWriteSectionConfig {
731 pub fn git_program(&self) -> &Path {
732 self.program.as_deref().unwrap_or_else(|| Path::new("git"))
733 }
734
735 pub fn validate_dev_loop(&self) -> Result<(), ConfigError> {
736 let invalid = |key: &str, reason: &str| ConfigError::InvalidGitWriteConfig {
737 key: key.to_string(),
738 reason: reason.to_string(),
739 };
740 if let Some(program) = &self.program {
741 if !program.is_absolute() {
742 return Err(invalid("git_write.program", "must be absolute"));
743 }
744 let metadata = std::fs::metadata(program).map_err(|error| {
745 if error.kind() == std::io::ErrorKind::NotFound {
746 invalid("git_write.program", "does not exist")
747 } else {
748 invalid("git_write.program", &format!("is not executable: {error}"))
749 }
750 })?;
751 #[cfg(unix)]
752 let executable = {
753 use std::os::unix::fs::PermissionsExt;
754 metadata.permissions().mode() & 0o111 != 0
755 };
756 #[cfg(windows)]
757 let executable = program
758 .extension()
759 .and_then(|extension| extension.to_str())
760 .is_some_and(|extension| {
761 extension.eq_ignore_ascii_case("exe") || extension.eq_ignore_ascii_case("com")
762 });
763 #[cfg(not(any(unix, windows)))]
764 let executable = false;
765 if !metadata.is_file() || !executable {
766 return Err(invalid("git_write.program", "is not executable"));
767 }
768 }
769 if self.contract_faults && !cfg!(feature = "contract-faults") {
770 tracing::error!(
771 target: "khive.boot",
772 "[git_write] contract_faults requires the test-only contract-faults build feature"
773 );
774 return Err(invalid(
775 "contract_faults",
776 "requires the test-only contract-faults build feature",
777 ));
778 }
779 if let Some(fault) = &self.fault {
780 if !self.contract_faults {
781 return Err(invalid("fault", "requires contract_faults = true"));
782 }
783 let valid = fault.split_once(':').is_some_and(|(verb, point)| {
784 matches!(verb, "git.push" | "git.pr_merge")
785 && matches!(
786 point,
787 "reply-lost-after-effect" | "audit-fails-after-effect"
788 )
789 });
790 if !valid {
791 return Err(invalid("fault", "unsupported contract fault selector"));
792 }
793 }
794 for (path, repository) in &self.repositories {
795 let key = format!("repositories.{path}.merge_refusals");
796 let mut seen: Vec<&str> = Vec::new();
797 for entry in &repository.merge_refusals {
798 if !GitWriteRepositoryConfig::MERGE_REFUSALS.contains(&entry.as_str()) {
799 return Err(invalid(&key, "entries must be opener or last_pusher"));
800 }
801 if seen.contains(&entry.as_str()) {
802 return Err(invalid(&key, "entries must not repeat"));
803 }
804 seen.push(entry);
805 }
806 }
807 // The default keychain program is Unix-only. Legacy configurations with
808 // no actor mappings cannot invoke it, so they remain loadable elsewhere.
809 if !cfg!(unix)
810 && self.actors.is_empty()
811 && self.credential_resolver == default_git_credential_resolver()
812 {
813 return Ok(());
814 }
815 let argv = &self.credential_resolver;
816 let Some(program) = argv.first() else {
817 return Err(invalid("credential_resolver", "argv must not be empty"));
818 };
819 let program_path = Path::new(program);
820 if !program_path.is_absolute() {
821 return Err(invalid(
822 "credential_resolver",
823 "argv[0] must be an absolute path",
824 ));
825 }
826 let program_name = program_path
827 .file_name()
828 .and_then(|name| name.to_str())
829 .unwrap_or_default()
830 .to_ascii_lowercase();
831 if matches!(
832 program_name.trim_end_matches(".exe"),
833 "sh" | "bash"
834 | "dash"
835 | "zsh"
836 | "ksh"
837 | "fish"
838 | "csh"
839 | "tcsh"
840 | "cmd"
841 | "powershell"
842 | "pwsh"
843 | "env"
844 ) {
845 return Err(invalid(
846 "credential_resolver",
847 "shell or env launcher is not allowed",
848 ));
849 }
850 if argv.iter().any(|arg| arg.chars().any(char::is_control)) {
851 return Err(invalid(
852 "credential_resolver",
853 "argv must not contain control characters",
854 ));
855 }
856 if program.contains(['{', '}'])
857 || argv[1..]
858 .iter()
859 .any(|arg| arg != "{ref}" && arg.contains(['{', '}']))
860 {
861 return Err(invalid(
862 "credential_resolver",
863 "{ref} must be a complete argument and is the only allowed template",
864 ));
865 }
866 if !argv[1..].iter().any(|arg| arg == "{ref}") {
867 return Err(invalid(
868 "credential_resolver",
869 "argv must contain a {ref} argument",
870 ));
871 }
872 for (actor, identity) in &self.actors {
873 if actor.trim().is_empty() || actor.chars().any(char::is_control) {
874 return Err(invalid(
875 "actors",
876 "actor labels must be nonempty and contain no control characters",
877 ));
878 }
879 for (field, value) in [
880 ("name", &identity.name),
881 ("email", &identity.email),
882 ("credential_ref", &identity.credential_ref),
883 ("platform_identity", &identity.platform_identity),
884 ] {
885 if value.trim().is_empty() || value.chars().any(char::is_control) {
886 return Err(invalid(
887 &format!("actors.{actor}.{field}"),
888 "must be nonempty and contain no control characters",
889 ));
890 }
891 }
892 if identity.name.contains(['<', '>']) || identity.email.contains(['<', '>']) {
893 return Err(invalid(
894 &format!("actors.{actor}"),
895 "name and email must not contain Git identity delimiters",
896 ));
897 }
898 }
899 Ok(())
900 }
901}
902
903// ---- exec sandbox (ADR-181) ----
904
905/// `[exec.limits]`: per-run resource limits applied to the sandboxed child
906/// and inherited by its descendants (`setrlimit` before exec).
907#[derive(Debug, Clone, Deserialize, Default)]
908pub struct ExecLimitsConfig {
909 #[serde(default)]
910 pub cpu_seconds: Option<u64>,
911 #[serde(default)]
912 pub address_space: Option<u64>,
913 #[serde(default)]
914 pub file_size: Option<u64>,
915 #[serde(default)]
916 pub nproc: Option<u64>,
917}
918
919/// `[exec]` section (ADR-181): where runs materialize, what they may read,
920/// which caller environment keys pass through, which executable paths the
921/// `never` list matches, and the output caps, wall-clock defaults and resource
922/// limits. `never` matches paths, not a program's capabilities (ADR-181 A9).
923///
924/// ```toml
925/// [exec]
926/// root = "/var/lib/khive/exec"
927/// read_roots = ["/opt/toolchains/python3.11"]
928/// env = ["SOURCE_DATE_EPOCH"]
929/// # The never list matches resolved executable paths, not renamed copies.
930/// never = ["/usr/bin/curl"]
931/// max_output_bytes = 1048576
932/// timeout_default_s = 30
933/// timeout_max_s = 600
934/// binary_digest_timeout_s = 10 # 1..=60; independent of run timeout
935/// keep = false
936///
937/// [exec.limits]
938/// cpu_seconds = 60
939/// file_size = 104857600
940/// ```
941#[derive(Debug, Clone, Deserialize, Default)]
942#[serde(deny_unknown_fields)]
943pub struct ExecSectionConfig {
944 #[serde(default)]
945 pub root: Option<String>,
946 #[serde(default)]
947 pub read_roots: Vec<String>,
948 #[serde(default)]
949 pub env: Vec<String>,
950 #[serde(default)]
951 pub never: Vec<String>,
952 #[serde(default)]
953 pub max_output_bytes: Option<u64>,
954 #[serde(default)]
955 pub timeout_default_s: Option<f64>,
956 #[serde(default)]
957 pub timeout_max_s: Option<f64>,
958 /// Stalled-mount guard for hashing an authorized binary (1..=60 seconds).
959 #[serde(default)]
960 pub binary_digest_timeout_s: Option<u64>,
961 #[serde(default)]
962 pub keep: bool,
963 #[serde(default)]
964 pub limits: ExecLimitsConfig,
965}
966
967pub const DEFAULT_EXEC_BINARY_DIGEST_TIMEOUT_S: u64 = 10;
968pub const MAX_EXEC_BINARY_DIGEST_TIMEOUT_S: u64 = 60;
969
970// ---- web fetch/search policy (ADR-175 Amendment 1, carried into ADR-191 D3) ----
971
972/// One `[[web.allowlist]]` entry: an exclusive host the operator has opted
973/// into reachability for `web.fetch`/`web.search`. Presence of ANY entry
974/// makes the allowlist exclusive (ADR-175 A1.2.3); absence leaves the public
975/// internet reachable subject to the other egress rules. Matched by exact,
976/// normalized (lowercase, trailing-dot-stripped) host equality only — no
977/// suffix wildcarding, unlike `[[web.credentials]].hosts` (A1.2.6).
978#[derive(Debug, Clone, Deserialize, Serialize)]
979#[serde(deny_unknown_fields)]
980pub struct WebAllowlistEntry {
981 pub host: String,
982}
983
984/// One `[[web.credentials]]` entry: a named secret, read from the process
985/// environment at request time (never accepted as a verb argument), bound to
986/// the set of hosts it may be presented to. Each `hosts` entry is either an
987/// exact IP-literal address (matched exactly, never as a suffix) or a
988/// hostname suffix (`example.com` matches `example.com` and any
989/// `*.example.com` at a DNS label boundary) — ADR-175 A1.2.6.
990#[derive(Debug, Clone, Deserialize, Serialize)]
991#[serde(deny_unknown_fields)]
992pub struct WebCredentialConfig {
993 /// Name the caller passes as `web.fetch`'s `credential` argument.
994 pub name: String,
995 /// Process environment variable holding the secret value.
996 pub env_var: String,
997 /// Non-empty set of hosts (exact IP literals or hostname suffixes) this
998 /// credential may be presented to.
999 pub hosts: Vec<String>,
1000}
1001
1002/// One canned result inside a `kind = "fixture"` `[[web.search_providers]]`
1003/// entry — deterministic, non-networked search results (demos, offline
1004/// corpora, and the fixture arm of `web.search`'s own test suite).
1005#[derive(Debug, Clone, Deserialize, Serialize)]
1006#[serde(deny_unknown_fields)]
1007pub struct WebFixtureResult {
1008 pub title: String,
1009 pub url: String,
1010 pub snippet: String,
1011}
1012
1013/// One `[[web.search_providers]]` entry (ADR-175 A1.3). The provider is
1014/// operator configuration; `web.search`'s `provider` argument only selects
1015/// among entries declared here by `name`. Closed, tagged on `kind`.
1016#[derive(Debug, Clone, Deserialize, Serialize)]
1017#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
1018pub enum WebSearchProviderConfig {
1019 /// Deterministic canned results — no outbound request.
1020 Fixture {
1021 name: String,
1022 #[serde(default)]
1023 default: bool,
1024 results: Vec<WebFixtureResult>,
1025 },
1026 /// A real HTTP GET search backend. `url_template` must contain the
1027 /// literal substring `{query}`, replaced with the percent-encoded query
1028 /// at request time; `{limit}` is replaced with the effective limit when
1029 /// present. The response body is JSON: an array of `{title, url,
1030 /// snippet}` objects. `api_key_env`, when set, is a process environment
1031 /// variable sent as `Authorization: Bearer <value>`.
1032 Http {
1033 name: String,
1034 #[serde(default)]
1035 default: bool,
1036 url_template: String,
1037 #[serde(default)]
1038 api_key_env: Option<String>,
1039 /// Hosts (exact IP literals or hostname suffixes) `api_key_env`'s
1040 /// value may be presented to, modeled on
1041 /// `[[web.credentials]].hosts`. Required non-empty whenever
1042 /// `api_key_env` is set (`validate` enforces this) — an unscoped key
1043 /// would ride along to whatever host `url_template` resolves to,
1044 /// which defeats the point of scoping it at all.
1045 #[serde(default)]
1046 hosts: Vec<String>,
1047 },
1048}
1049
1050impl WebSearchProviderConfig {
1051 pub fn name(&self) -> &str {
1052 match self {
1053 WebSearchProviderConfig::Fixture { name, .. } => name,
1054 WebSearchProviderConfig::Http { name, .. } => name,
1055 }
1056 }
1057
1058 pub fn is_default(&self) -> bool {
1059 match self {
1060 WebSearchProviderConfig::Fixture { default, .. } => *default,
1061 WebSearchProviderConfig::Http { default, .. } => *default,
1062 }
1063 }
1064}
1065
1066/// `[web]` section (ADR-175 Amendment 1, ADR-191 D3): operator policy for
1067/// `web.fetch` and `web.search` — ceilings, the address allowlist, credential
1068/// host-set bindings, and configured search providers.
1069///
1070/// No generic per-pack settings map exists in this file today (`PackConfig`
1071/// carries only `backend`/`no_embed`, both storage-routing concerns) so this
1072/// follows the established precedent for a pack needing rich operator policy:
1073/// a dedicated top-level section threaded through `RuntimeConfig`, the same
1074/// shape as `[exec]` and `[git_write]`.
1075///
1076/// ```toml
1077/// [web]
1078/// timeout_default_s = 30
1079/// timeout_max_s = 120
1080/// max_bytes_default = 5242880
1081/// max_bytes_max = 52428800
1082/// search_limit_default = 10
1083/// search_limit_max = 50
1084///
1085/// [[web.allowlist]]
1086/// host = "example.com"
1087///
1088/// [[web.credentials]]
1089/// name = "example-token"
1090/// env_var = "EXAMPLE_API_TOKEN"
1091/// hosts = ["example.com"]
1092///
1093/// [[web.search_providers]]
1094/// kind = "fixture"
1095/// name = "demo"
1096/// default = true
1097/// results = [{ title = "Example", url = "https://example.com", snippet = "..." }]
1098/// ```
1099#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1100#[serde(deny_unknown_fields)]
1101pub struct WebSectionConfig {
1102 #[serde(default)]
1103 pub timeout_default_s: Option<u64>,
1104 #[serde(default)]
1105 pub timeout_max_s: Option<u64>,
1106 #[serde(default)]
1107 pub max_bytes_default: Option<u64>,
1108 #[serde(default)]
1109 pub max_bytes_max: Option<u64>,
1110 #[serde(default)]
1111 pub search_limit_default: Option<u32>,
1112 #[serde(default)]
1113 pub search_limit_max: Option<u32>,
1114 #[serde(default)]
1115 pub allowlist: Vec<WebAllowlistEntry>,
1116 #[serde(default)]
1117 pub credentials: Vec<WebCredentialConfig>,
1118 #[serde(default)]
1119 pub search_providers: Vec<WebSearchProviderConfig>,
1120 /// Directories `web.ingest`'s disk mode may read from, modeled on
1121 /// `[exec] read_roots`. Absent or empty fails closed: disk ingest is
1122 /// refused entirely until the operator names at least one root.
1123 #[serde(default)]
1124 pub read_roots: Vec<String>,
1125}
1126
1127/// Effective operator bounds shared by file validation and programmatic web dispatch.
1128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1129pub struct WebCeilings {
1130 pub timeout_default_s: u64,
1131 pub timeout_max_s: u64,
1132 pub max_bytes_default: u64,
1133 pub max_bytes_max: u64,
1134 pub search_limit_default: u32,
1135 pub search_limit_max: u32,
1136}
1137
1138impl Default for WebCeilings {
1139 fn default() -> Self {
1140 Self {
1141 timeout_default_s: 30,
1142 timeout_max_s: 120,
1143 max_bytes_default: 5 * 1024 * 1024,
1144 max_bytes_max: 50 * 1024 * 1024,
1145 search_limit_default: 10,
1146 search_limit_max: 50,
1147 }
1148 }
1149}
1150
1151impl WebSectionConfig {
1152 pub fn resolved_ceilings(&self) -> Result<WebCeilings, ConfigError> {
1153 let defaults = WebCeilings::default();
1154 let bounds = WebCeilings {
1155 timeout_default_s: self.timeout_default_s.unwrap_or(defaults.timeout_default_s),
1156 timeout_max_s: self.timeout_max_s.unwrap_or(defaults.timeout_max_s),
1157 max_bytes_default: self.max_bytes_default.unwrap_or(defaults.max_bytes_default),
1158 max_bytes_max: self.max_bytes_max.unwrap_or(defaults.max_bytes_max),
1159 search_limit_default: self
1160 .search_limit_default
1161 .unwrap_or(defaults.search_limit_default),
1162 search_limit_max: self.search_limit_max.unwrap_or(defaults.search_limit_max),
1163 };
1164 for (key, default, maximum, maximum_key) in [
1165 (
1166 "timeout_default_s",
1167 bounds.timeout_default_s,
1168 bounds.timeout_max_s,
1169 "timeout_max_s",
1170 ),
1171 (
1172 "max_bytes_default",
1173 bounds.max_bytes_default,
1174 bounds.max_bytes_max,
1175 "max_bytes_max",
1176 ),
1177 (
1178 "search_limit_default",
1179 u64::from(bounds.search_limit_default),
1180 u64::from(bounds.search_limit_max),
1181 "search_limit_max",
1182 ),
1183 ] {
1184 if default == 0 || default > maximum {
1185 return Err(ConfigError::InvalidWebConfig {
1186 key: key.into(),
1187 reason: format!(
1188 "resolved default must be positive and not exceed {maximum_key}={maximum}"
1189 ),
1190 });
1191 }
1192 }
1193 if std::time::Instant::now()
1194 .checked_add(std::time::Duration::from_secs(bounds.timeout_max_s))
1195 .is_none()
1196 {
1197 return Err(ConfigError::InvalidWebConfig {
1198 key: "timeout_max_s".into(),
1199 reason: "cannot be represented as a request deadline".into(),
1200 });
1201 }
1202 Ok(bounds)
1203 }
1204
1205 pub fn validate(&self) -> Result<(), ConfigError> {
1206 self.resolved_ceilings()?;
1207 let invalid = |key: &str, reason: &str| ConfigError::InvalidWebConfig {
1208 key: key.to_string(),
1209 reason: reason.to_string(),
1210 };
1211 let mut seen_hosts = std::collections::HashSet::new();
1212 for entry in &self.allowlist {
1213 let normalized = entry.host.trim().trim_end_matches('.').to_ascii_lowercase();
1214 if normalized.is_empty() {
1215 return Err(invalid("allowlist.host", "must not be empty"));
1216 }
1217 if !seen_hosts.insert(normalized) {
1218 return Err(invalid("allowlist.host", "duplicate host entry"));
1219 }
1220 }
1221 let mut seen_credentials = std::collections::HashSet::new();
1222 for credential in &self.credentials {
1223 if credential.name.trim().is_empty() {
1224 return Err(invalid("credentials.name", "must not be empty"));
1225 }
1226 if !seen_credentials.insert(credential.name.clone()) {
1227 return Err(invalid("credentials.name", "duplicate credential name"));
1228 }
1229 if credential.env_var.trim().is_empty() {
1230 return Err(invalid("credentials.env_var", "must not be empty"));
1231 }
1232 if credential.hosts.is_empty() {
1233 return Err(invalid(
1234 "credentials.hosts",
1235 "must name at least one host or suffix",
1236 ));
1237 }
1238 }
1239 let mut seen_providers = std::collections::HashSet::new();
1240 let mut default_count = 0;
1241 for provider in &self.search_providers {
1242 let name = provider.name();
1243 if name.trim().is_empty() {
1244 return Err(invalid("search_providers.name", "must not be empty"));
1245 }
1246 if !seen_providers.insert(name.to_string()) {
1247 return Err(invalid("search_providers.name", "duplicate provider name"));
1248 }
1249 if provider.is_default() {
1250 default_count += 1;
1251 }
1252 if let WebSearchProviderConfig::Http {
1253 url_template,
1254 api_key_env,
1255 hosts,
1256 ..
1257 } = provider
1258 {
1259 if !url_template.contains("{query}") {
1260 return Err(invalid(
1261 "search_providers.url_template",
1262 "must contain the literal substring {query}",
1263 ));
1264 }
1265 if api_key_env.is_some() && hosts.is_empty() {
1266 return Err(invalid(
1267 "search_providers.hosts",
1268 "an api_key_env-bearing provider must name at least one host or suffix",
1269 ));
1270 }
1271 }
1272 }
1273 if default_count > 1 {
1274 return Err(invalid(
1275 "search_providers",
1276 "at most one provider may set default = true",
1277 ));
1278 }
1279 Ok(())
1280 }
1281}
1282
1283/// Top-level khive configuration loaded from `khive.toml` or `config.toml`.
1284///
1285/// Sections consumed today:
1286/// - `[[engines]]`: embedding engine declarations
1287/// - `[actor]`: default namespace / identity (OSS actor model)
1288/// - `[gate]`: built-in caller enrollment
1289/// - `[runtime]`: runtime knobs (pack selection, brain profile, output format)
1290/// - `[brain]`: actor read policy
1291/// - `[telemetry]`: stream and channel carrier policy
1292/// - `[[backends]]`: storage backend declarations (ADR-028)
1293/// - `[packs.<name>]`: per-pack backend assignments (ADR-028)
1294/// - `[display]`: rendering timezone (ADR-169)
1295/// - `[blob]`: server-local file-transfer opt-in
1296///
1297/// Unknown top-level keys are silently ignored by serde for forward
1298/// compatibility. The `[actor]`, `[gate]`, `[brain]`, `[blob]`, and `[telemetry]` tables are closed
1299/// with `deny_unknown_fields` so a misspelled policy key always fails startup.
1300/// `[storage]`, `[[backends]]` entries and `[exec]` are also closed so unknown
1301/// destination keys cannot be silently dropped, as are `[web]` and `[[mounts]]` entries.
1302#[derive(Debug, Clone, Deserialize, Default)]
1303pub struct KhiveConfig {
1304 /// Read by `credentials::read_tables` at load, never by this derive.
1305 #[serde(skip)]
1306 pub credentials: Vec<crate::credentials::CredentialConfig>,
1307
1308 #[serde(skip)]
1309 pub visibility_receipts: Option<crate::credentials::VisibilityReceiptConfig>,
1310
1311 #[serde(default)]
1312 pub mounts: Vec<crate::mount_config::MountConfig>,
1313
1314 /// Typed only so a top-level `db` key can be rejected loudly by
1315 /// [`KhiveConfig::validate`] instead of being silently ignored as an
1316 /// unknown key. Not a supported config-file storage selector: single-file
1317 /// database selection is `--db`/`KHIVE_DB`, and storage topology is
1318 /// `[[backends]].path`.
1319 #[serde(default)]
1320 pub db: Option<String>,
1321
1322 /// Embedding engine declarations.
1323 #[serde(default)]
1324 pub engines: Vec<EngineConfig>,
1325
1326 /// Default actor identity for this khive instance.
1327 ///
1328 /// When present, `actor.id` feeds configuration identity and gate/attribution
1329 /// policy input. A non-`'local'` `actor.id` is folded into the default READ
1330 /// visible-set at config load (ADR-007 Rev 4 Rule 3b) — it widens what default
1331 /// multi-record reads return, but never routes writes or sets `default_namespace`.
1332 /// Cloud model derives actor identity from an authenticated token.
1333 #[serde(default)]
1334 pub actor: ActorConfig,
1335
1336 /// Optional caller-enrollment policy. A present, even empty, table is an
1337 /// explicit fail-closed policy; an absent table preserves the runtime's
1338 /// existing gate.
1339 #[serde(default)]
1340 pub gate: Option<GateSectionConfig>,
1341
1342 /// Runtime knobs: namespace overrides, brain profile, etc.
1343 #[serde(default)]
1344 pub runtime: RuntimeSectionConfig,
1345
1346 /// Named storage backends (ADR-028).
1347 ///
1348 /// When absent or empty, a single implicit `main` backend is used and all
1349 /// packs share it — identical to pre-ADR-028 behavior.
1350 #[serde(default)]
1351 pub backends: Vec<BackendConfig>,
1352
1353 /// Per-pack backend assignments (ADR-028).
1354 ///
1355 /// Maps pack name to backend name. Packs absent from this map fall back to
1356 /// the `main` backend. Validated at load time: every referenced backend name
1357 /// must appear in `backends`.
1358 #[serde(default)]
1359 pub packs: std::collections::HashMap<String, PackConfig>,
1360
1361 /// Actor read policy. An absent or empty list grants no fleet-wide reads.
1362 #[serde(default)]
1363 pub brain: BrainSectionConfig,
1364
1365 /// Git-write policy allowlist (ADR-108 Amendment). Absent or empty
1366 /// `allowed` fails closed — `khive-pack-git`'s write verbs are
1367 /// unavailable until this section is populated.
1368 #[serde(default)]
1369 pub git_write: GitWriteSectionConfig,
1370
1371 /// Server-local file-transfer opt-in. Other blob verbs are unaffected.
1372 #[serde(default)]
1373 pub blob: BlobSectionConfig,
1374
1375 /// Storage-layer config not covered by `[[backends]]` (ADR-111
1376 /// Amendment 2: `[storage.blob]`'s `fs`/`s3` selector).
1377 #[serde(default)]
1378 pub storage: StorageSectionConfig,
1379
1380 /// Exec sandbox section (ADR-181). Absent means no runs: the exec pack
1381 /// refuses every `exec.run` until `[exec] read_roots` names a toolchain.
1382 #[serde(default)]
1383 pub exec: ExecSectionConfig,
1384
1385 /// Stream and channel carrier policy. Unclassified kinds default to ephemeral.
1386 #[serde(default)]
1387 pub telemetry: crate::telemetry_config::TelemetryConfig,
1388
1389 /// Rendering timezone configuration (ADR-169). Absent `timezone` resolves
1390 /// to the host's local zone at [`RuntimeConfig`](crate::RuntimeConfig)
1391 /// construction time.
1392 #[serde(default)]
1393 pub display: DisplaySectionConfig,
1394
1395 /// `web.fetch`/`web.search` operator policy (ADR-175 Amendment 1).
1396 /// Absent is the fail-closed default for search (no provider configured)
1397 /// and the permissive-subject-to-address-rules default for fetch (no
1398 /// allowlist configured).
1399 #[serde(default)]
1400 pub web: WebSectionConfig,
1401}
1402
1403/// `[runtime]` section in `khive.toml`.
1404///
1405/// Carries runtime knobs resolved during process construction. Most mirror a
1406/// CLI flag / environment tier; field documentation calls out exceptions.
1407/// All fields are optional and preserve their already-resolved base value when
1408/// absent.
1409#[derive(Debug, Clone, Deserialize, Default)]
1410pub struct RuntimeSectionConfig {
1411 /// Packs to load when neither `--pack` nor `KHIVE_PACKS` selects them.
1412 /// An absent or empty list preserves the built-in production default.
1413 #[serde(default)]
1414 pub packs: Option<Vec<String>>,
1415
1416 /// Brain profile ID to use for `memory.feedback` / `knowledge.feedback`
1417 /// and recall-time score boosting (ADR-035 §Brain profile configuration).
1418 ///
1419 /// Mirrors `--brain-profile` / `KHIVE_BRAIN_PROFILE`. When absent, the
1420 /// namespace-bound profile (via `brain.resolve`) is tried, then the
1421 /// global tuning prior is used as the final fallback.
1422 #[serde(default)]
1423 pub brain_profile: Option<String>,
1424
1425 /// Default output serialization format (ADR-078).
1426 ///
1427 /// Mirrors `--output-format` / `KHIVE_OUTPUT_FORMAT`. Precedence (highest to lowest):
1428 /// per-request `format` field → `KHIVE_OUTPUT_FORMAT` → this field → builtin `json`.
1429 ///
1430 /// Accepted values: `"json"` (default), `"auto"`, `"table"`.
1431 #[serde(default)]
1432 pub default_output_format: Option<OutputFormat>,
1433
1434 /// Aggregate process-local admission budget for digest-verified blob
1435 /// hydration (ADR-160 D3), in raw bytes.
1436 ///
1437 /// This knob has no environment-variable counterpart. When absent, the
1438 /// resolved runtime keeps its built-in 256 MiB default (or a value supplied
1439 /// directly through `RuntimeConfig`).
1440 #[serde(default)]
1441 pub blob_hydration_bytes: Option<u64>,
1442}
1443
1444/// `[display]` section in `khive.toml` — the timezone khive anchors date-only
1445/// input to (ADR-169 Implementation step 1).
1446///
1447/// ```toml
1448/// [display]
1449/// timezone = "America/New_York"
1450/// ```
1451#[derive(Debug, Clone, Deserialize, Default)]
1452pub struct DisplaySectionConfig {
1453 /// IANA zone name (e.g. `"America/New_York"`, `"Asia/Tokyo"`, `"UTC"`).
1454 /// Validated at load time against `chrono_tz::Tz`'s zone table — an
1455 /// unrecognized name is a startup error, not a silent fallback. Absent →
1456 /// the host's local zone, resolved once via `iana-time-zone` and falling
1457 /// back to UTC when the host zone cannot be determined.
1458 #[serde(default)]
1459 pub timezone: Option<String>,
1460}
1461
1462impl KhiveConfig {
1463 /// Load and validate a `KhiveConfig` from an explicit path.
1464 ///
1465 /// Search order:
1466 /// 1. `path` argument (explicit override — e.g. from `--config` / `KHIVE_CONFIG`)
1467 /// 2. `./.khive/config.toml` (project-local config, relative to the MCP server cwd)
1468 ///
1469 /// The project-local default collocates config with the `khive-test.db` that already
1470 /// lives under `.khive/` in each project directory. `~/.khive/config.toml` is searched
1471 /// by [`KhiveConfig::load_with_home_fallback`] when the project-local file is absent.
1472 ///
1473 /// If the resolved file does **not exist**, returns `Ok(None)`.
1474 /// A missing config is not an error — callers fall back to the env-var path.
1475 ///
1476 /// If the file exists but cannot be parsed, returns a `ConfigError`.
1477 /// After parsing, `validate()` runs and any logical errors are returned.
1478 pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
1479 let resolved = match path {
1480 Some(p) => p.to_path_buf(),
1481 None => PathBuf::from(".khive/config.toml"),
1482 };
1483
1484 if !resolved.exists() {
1485 return Ok(None);
1486 }
1487
1488 // Diagnostics name the canonical path so an error is actionable from
1489 // any cwd; resolution keeps using `resolved` as given.
1490 let diagnostic_path = std::fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1491 let raw = std::fs::read_to_string(&resolved)
1492 .map_err(|source| ConfigError::from(source).in_file(&diagnostic_path))?;
1493 let mut cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
1494 path: diagnostic_path.clone(),
1495 source,
1496 })?;
1497 crate::credentials::read_tables(&raw, &mut cfg)
1498 .map_err(|error| ConfigError::from(error).in_file(&diagnostic_path))?;
1499 cfg.validate()
1500 .map_err(|error| error.in_file(&diagnostic_path))?;
1501 Ok(Some(cfg))
1502 }
1503
1504 /// Load config with the full resolution order:
1505 ///
1506 /// 1. Explicit `path` (from `--config` / `KHIVE_CONFIG`)
1507 /// 2. `./khive.toml` (project-local, project root)
1508 /// 3. `<db-dir>/config.toml` (project-local, anchored to the resolved database's
1509 /// own directory — see `project_config_anchor_dir`)
1510 /// 4. `~/.khive/config.toml` (user-global)
1511 ///
1512 /// Returns the first file found, or `Ok(None)` when none exist.
1513 /// Parse errors are propagated immediately — a malformed config is always
1514 /// an error regardless of which tier it came from.
1515 ///
1516 /// The explicit tier (1) is stricter than the discovery tiers: a `path`
1517 /// that names a file which does not exist returns
1518 /// [`ConfigError::ExplicitConfigMissing`] instead of falling through to
1519 /// tiers 2-4 — an operator-selected config that is missing is the same
1520 /// class of mistake as one that is malformed, and silently discovering a
1521 /// different file would boot against a config the operator did not select
1522 /// (ADR-035).
1523 ///
1524 /// `db_path` should be the same database path the caller is about to open
1525 /// (or has already resolved). Passing it makes tier 3 resolve identically
1526 /// for any two processes that target the same database, regardless of
1527 /// their process working directory — this is what lets a thin client and
1528 /// a warm daemon serving the same database agree on one config file. Pass
1529 /// `None` when no database path is known yet; tier 3 then falls back to
1530 /// the process cwd, matching the pre-existing behavior.
1531 pub fn load_with_home_fallback(
1532 path: Option<&Path>,
1533 db_path: Option<&Path>,
1534 ) -> Result<Option<Self>, ConfigError> {
1535 Ok(Self::load_with_home_fallback_and_source(path, db_path)?.map(|(config, _)| config))
1536 }
1537
1538 /// Load config with the full resolution order and retain the exact file
1539 /// that supplied it.
1540 ///
1541 /// This is the diagnostic-preserving form of
1542 /// [`KhiveConfig::load_with_home_fallback`]. Runtime callers that need to
1543 /// tell an operator which selected file must be edited should use this
1544 /// method instead of reconstructing the discovery order independently.
1545 pub fn load_with_home_fallback_and_source(
1546 path: Option<&Path>,
1547 db_path: Option<&Path>,
1548 ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1549 // Tier 1: explicit path (highest priority). An explicit selection
1550 // naming a MISSING file fails loud here — once, at the loader, for
1551 // every entry point — instead of silently falling through to the
1552 // discovery tiers: a mistyped path would otherwise boot against a
1553 // config the operator did not select (ADR-035: an entry point must
1554 // not document an explicit tier while silently falling back to
1555 // discovery). Tiers 2-4 keep their tolerant contract.
1556 if let Some(p) = path {
1557 if !p.exists() {
1558 return Err(ConfigError::ExplicitConfigMissing {
1559 path: p.to_path_buf(),
1560 });
1561 }
1562 return Ok(Self::load(Some(p))?.map(|config| (config, Self::diagnostic_config_path(p))));
1563 }
1564
1565 // Tiers 2-4: search project root, db-anchored hidden dir, user-global.
1566 let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1567 let home_root = std::env::var_os("HOME").map(PathBuf::from);
1568 Self::load_with_roots_and_source(&project_root, home_root.as_deref(), db_path)
1569 }
1570
1571 /// Testable inner search: tiers 2-4, given explicit roots instead of
1572 /// reading `cwd` and `HOME` from process state.
1573 ///
1574 /// - Tier 2: `<project_root>/khive.toml` (still cwd-anchored — unchanged)
1575 /// - Tier 3: `<db_dir>/config.toml`, anchored to `db_path` rather than
1576 /// `project_root` (see `project_config_anchor_dir`); falls back to
1577 /// `<project_root>/.khive/config.toml` when `db_path` is `None`
1578 /// - Tier 4: `<home_root>/.khive/config.toml` (skipped when `None`)
1579 #[cfg(test)]
1580 pub(crate) fn load_with_roots(
1581 project_root: &Path,
1582 home_root: Option<&Path>,
1583 db_path: Option<&Path>,
1584 ) -> Result<Option<Self>, ConfigError> {
1585 Ok(
1586 Self::load_with_roots_and_source(project_root, home_root, db_path)?
1587 .map(|(config, _)| config),
1588 )
1589 }
1590
1591 fn load_with_roots_and_source(
1592 project_root: &Path,
1593 home_root: Option<&Path>,
1594 db_path: Option<&Path>,
1595 ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1596 // Tier 2: project root khive.toml.
1597 let tier2 = project_root.join("khive.toml");
1598 if tier2.exists() {
1599 return Ok(Self::load(Some(&tier2))?
1600 .map(|config| (config, Self::diagnostic_config_path(&tier2))));
1601 }
1602
1603 // Tier 3: project-local hidden dir, anchored to the resolved database's
1604 // own directory instead of the process cwd.
1605 let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
1606 if tier3.exists() {
1607 return Ok(Self::load(Some(&tier3))?
1608 .map(|config| (config, Self::diagnostic_config_path(&tier3))));
1609 }
1610
1611 // Tier 4: user-global ~/.khive/config.toml.
1612 if let Some(home) = home_root {
1613 let tier4 = home.join(".khive/config.toml");
1614 if tier4.exists() {
1615 return Ok(Self::load(Some(&tier4))?
1616 .map(|config| (config, Self::diagnostic_config_path(&tier4))));
1617 }
1618 }
1619
1620 Ok(None)
1621 }
1622
1623 fn diagnostic_config_path(path: &Path) -> PathBuf {
1624 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1625 }
1626
1627 /// Resolve the directory searched for the tier-3 project-local config file.
1628 ///
1629 /// Anchored to the directory containing the resolved database file, not the
1630 /// process cwd: two processes at different working directories that open the
1631 /// same database agree on this directory, which is what keeps their
1632 /// `config_id` fingerprints in sync (a client and a warm daemon serving the
1633 /// same database must resolve identical config so the daemon accepts the
1634 /// client's forwarded requests instead of rejecting them on a config
1635 /// mismatch).
1636 ///
1637 /// `db_path` is canonicalized first so symlinks/relative components collapse
1638 /// to the same absolute directory regardless of caller cwd. The database file
1639 /// may not exist yet (first run before anything has been written) — in that
1640 /// case canonicalization fails and the path is absolutized against
1641 /// `project_root` instead (or used as-is if already absolute); this must
1642 /// never panic, it is the expected cold-start case.
1643 ///
1644 /// If `db_dir` (the resolved database's parent directory) is itself named
1645 /// `.khive`, the config lives directly inside it (`<db_dir>/config.toml`) —
1646 /// this is the common case where the database is `<root>/.khive/khive.db`.
1647 /// Otherwise the config lives in a `.khive` subdirectory of `db_dir`.
1648 ///
1649 /// `db_path == None` (e.g. an in-memory database, or no database path known
1650 /// yet) falls back to `<project_root>/.khive`, preserving the pre-existing
1651 /// cwd-anchored behavior for callers with no database to anchor on.
1652 fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
1653 let Some(db_path) = db_path else {
1654 return project_root.join(".khive");
1655 };
1656
1657 let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
1658 if db_path.is_absolute() {
1659 db_path.to_path_buf()
1660 } else {
1661 project_root.join(db_path)
1662 }
1663 });
1664
1665 let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
1666
1667 if db_dir.file_name().is_some_and(|name| name == ".khive") {
1668 db_dir
1669 } else {
1670 db_dir.join(".khive")
1671 }
1672 }
1673
1674 /// Validate the parsed config for logical consistency.
1675 ///
1676 /// Checks:
1677 /// - Exactly one engine has `default = true` (when the list is non-empty).
1678 /// - Engine names are unique.
1679 /// - Every engine model is recognized by the runtime's alias parser.
1680 /// - `fusion_weight`, when present, is finite and `> 0`, then rejected as
1681 /// unsupported until the retrieval path actually consumes it.
1682 pub fn validate(&self) -> Result<(), ConfigError> {
1683 crate::mount_config::validate_mounts(&self.mounts)?;
1684 self.git_write.validate_dev_loop()?;
1685 self.telemetry.validate()?;
1686 self.web.validate()?;
1687 crate::credentials::CredentialConfig::validate_all(&self.credentials)?;
1688 if let Some(receipts) = &self.visibility_receipts {
1689 receipts.validate(&self.credentials)?;
1690 }
1691
1692 // Reject a top-level `db` key loudly instead of letting serde's
1693 // forward-compatible unknown-key tolerance silently swallow it: a
1694 // config author expecting `db=` to select the database would
1695 // otherwise get silent divergence from `--db`/`KHIVE_DB`.
1696 if let Some(value) = self.db.as_deref() {
1697 if !value.is_empty() {
1698 return Err(ConfigError::UnsupportedTopLevelDb {
1699 value: value.to_string(),
1700 });
1701 }
1702 }
1703
1704 // ADR-181 resource limits: macOS returns EINVAL for RLIMIT_AS and
1705 // RLIMIT_DATA, and RLIMIT_NPROC counts every process of the uid, so
1706 // neither can bound one run. Refuse loudly instead of pretending.
1707 if cfg!(target_os = "macos") {
1708 if self.exec.limits.address_space.is_some() {
1709 return Err(ConfigError::InvalidExecConfig {
1710 key: "limits.address_space".to_string(),
1711 reason: "unsupported_on_platform: macOS does not enforce an address-space rlimit per process".to_string(),
1712 });
1713 }
1714 if self.exec.limits.nproc.is_some() {
1715 return Err(ConfigError::InvalidExecConfig {
1716 key: "limits.nproc".to_string(),
1717 reason: "unsupported_on_platform: RLIMIT_NPROC counts every process of the uid, not one run".to_string(),
1718 });
1719 }
1720 }
1721 // These defaults must agree with the exec pack's resolved defaults.
1722 // Validate the effective pair, including one-sided overrides, before
1723 // Duration conversion or a deadline can panic in exec.run.
1724 let default_timeout = self.exec.timeout_default_s.unwrap_or(30.0);
1725 let maximum_timeout = self.exec.timeout_max_s.unwrap_or(600.0);
1726 for (key, value) in [
1727 ("timeout_default_s", default_timeout),
1728 ("timeout_max_s", maximum_timeout),
1729 ] {
1730 let duration = value
1731 .is_finite()
1732 .then(|| std::time::Duration::try_from_secs_f64(value).ok())
1733 .flatten()
1734 .filter(|duration| !duration.is_zero());
1735 if duration
1736 .is_none_or(|duration| std::time::Instant::now().checked_add(duration).is_none())
1737 {
1738 return Err(ConfigError::InvalidExecConfig {
1739 key: key.to_string(),
1740 reason: "must be positive, finite, and representable as a deadline".to_string(),
1741 });
1742 }
1743 }
1744 if default_timeout > maximum_timeout {
1745 return Err(ConfigError::InvalidExecConfig {
1746 key: "timeout_default_s".to_string(),
1747 reason: format!(
1748 "default {default_timeout} exceeds timeout_max_s {maximum_timeout}"
1749 ),
1750 });
1751 }
1752 let binary_digest_timeout = self
1753 .exec
1754 .binary_digest_timeout_s
1755 .unwrap_or(DEFAULT_EXEC_BINARY_DIGEST_TIMEOUT_S);
1756 if !(1..=MAX_EXEC_BINARY_DIGEST_TIMEOUT_S).contains(&binary_digest_timeout) {
1757 return Err(ConfigError::InvalidExecConfig {
1758 key: "binary_digest_timeout_s".to_string(),
1759 reason: format!("must be between 1 and {MAX_EXEC_BINARY_DIGEST_TIMEOUT_S} seconds"),
1760 });
1761 }
1762
1763 if let Some(value) = self.runtime.blob_hydration_bytes {
1764 let min = khive_storage::MAX_BLOB_WHOLE_BYTES;
1765 let max = tokio::sync::Semaphore::MAX_PERMITS as u64;
1766 if value < min || value > max {
1767 return Err(ConfigError::InvalidBlobHydrationBytes { value, min, max });
1768 }
1769 }
1770
1771 // Validate actor.id when present — an invalid namespace is a startup error,
1772 // not a silent fallback.
1773 if let Some(id) = self.actor.id.as_deref() {
1774 if id.is_empty() {
1775 return Err(ConfigError::InvalidActorId {
1776 id: id.to_string(),
1777 reason: "actor.id must not be empty; remove the key or provide a value"
1778 .to_string(),
1779 });
1780 }
1781 Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
1782 id: id.to_string(),
1783 reason: e.to_string(),
1784 })?;
1785 }
1786
1787 self.actor
1788 .mailbox_gate(std::sync::Arc::new(khive_gate::AllowAllGate))
1789 .map_err(|error| ConfigError::InvalidMailboxReaders {
1790 reason: error.to_string(),
1791 })?;
1792
1793 if let Some(ref vis) = self.actor.visible_namespaces {
1794 for ns_str in vis {
1795 if ns_str.is_empty() {
1796 return Err(ConfigError::InvalidActorId {
1797 id: ns_str.clone(),
1798 reason: "visible_namespaces entries must not be empty".to_string(),
1799 });
1800 }
1801 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1802 id: ns_str.clone(),
1803 reason: format!("invalid visible namespace: {e}"),
1804 })?;
1805 }
1806 }
1807
1808 if let Some(gate) = &self.gate {
1809 khive_gate::CallerEnrollmentGate::validate_write_denials(&gate.deny_writes_for)
1810 .map_err(|error| ConfigError::InvalidWriteDenyPatterns {
1811 reason: error.to_string(),
1812 })?;
1813 for id in &gate.granted_actors {
1814 if id.is_empty() {
1815 return Err(ConfigError::InvalidGrantedActorId {
1816 id: id.clone(),
1817 reason: "actor ids must not be empty".to_string(),
1818 });
1819 }
1820 Namespace::parse(id).map_err(|error| ConfigError::InvalidGrantedActorId {
1821 id: id.clone(),
1822 reason: error.to_string(),
1823 })?;
1824 }
1825 }
1826
1827 // Validate actor.allowed_outbound_namespaces (fail-closed at startup on malformed entry).
1828 for ns_str in &self.actor.allowed_outbound_namespaces {
1829 if ns_str.is_empty() {
1830 return Err(ConfigError::InvalidActorId {
1831 id: ns_str.clone(),
1832 reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
1833 });
1834 }
1835 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1836 id: ns_str.clone(),
1837 reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
1838 })?;
1839 }
1840
1841 // Backend names must be unique.
1842 if !self.backends.is_empty() {
1843 let mut seen_backends = std::collections::HashSet::new();
1844 for backend in &self.backends {
1845 BackendId::parse(&backend.name).map_err(|error| {
1846 ConfigError::InvalidBackendName {
1847 name: backend.name.clone(),
1848 reason: error.to_string(),
1849 }
1850 })?;
1851 if backend
1852 .served_kinds
1853 .as_ref()
1854 .is_some_and(BTreeSet::is_empty)
1855 {
1856 return Err(ConfigError::EmptyBackendServedKinds {
1857 name: backend.name.clone(),
1858 });
1859 }
1860 if !seen_backends.insert(backend.name.clone()) {
1861 return Err(ConfigError::DuplicateBackendName {
1862 name: backend.name.clone(),
1863 });
1864 }
1865
1866 // The field's static invariants are checked when the config
1867 // file loads. The host resolves the environment fallback once
1868 // for every effective backend before forwarding or opening it.
1869 if backend.wal_ceiling_bytes.is_some() {
1870 resolve_wal_ceiling(
1871 backend.wal_ceiling_bytes,
1872 None,
1873 &backend.name,
1874 backend.kind.clone(),
1875 backend
1876 .journal_mode
1877 .as_deref()
1878 .is_none_or(|mode| mode.eq_ignore_ascii_case("wal")),
1879 backend.read_only,
1880 )?;
1881 }
1882
1883 backend.resolve_disk_guard(&khive_db::DiskGuardEnvironment::default())?;
1884
1885 // Reject fields that are parsed but not yet implemented: silently
1886 // accepting them would let misconfiguration slip past startup.
1887 if backend.cache_mb.is_some() {
1888 return Err(ConfigError::UnsupportedBackendField {
1889 name: backend.name.clone(),
1890 field: "cache_mb",
1891 });
1892 }
1893 if backend.journal_mode.is_some() {
1894 return Err(ConfigError::UnsupportedBackendField {
1895 name: backend.name.clone(),
1896 field: "journal_mode",
1897 });
1898 }
1899 }
1900 }
1901
1902 let defined: Vec<&str> = if self.backends.is_empty() {
1903 vec![BackendId::MAIN]
1904 } else {
1905 self.backends.iter().map(|b| b.name.as_str()).collect()
1906 };
1907 for (pack_name, pack_cfg) in &self.packs {
1908 if !defined.contains(&pack_cfg.backend.as_str()) {
1909 return Err(ConfigError::UnknownPackBackend {
1910 pack: pack_name.clone(),
1911 backend: pack_cfg.backend.clone(),
1912 defined: defined.join(", "),
1913 });
1914 }
1915 }
1916
1917 if !self.backends.is_empty() {
1918 let missing: Vec<_> = [SubstrateKind::Note, SubstrateKind::Entity]
1919 .into_iter()
1920 .filter(|kind| {
1921 !self.backends.iter().any(|backend| {
1922 backend
1923 .served_kinds
1924 .as_ref()
1925 .is_none_or(|served| served.contains(kind))
1926 })
1927 })
1928 .collect();
1929 if !missing.is_empty() {
1930 return Err(ConfigError::MissingBackendSearchKinds {
1931 kinds: missing,
1932 defined: defined.join(", "),
1933 });
1934 }
1935 }
1936
1937 // Validate [display] timezone (ADR-169): an unrecognized IANA zone
1938 // name is a startup error, not a silent fallback to the host zone.
1939 if let Some(tz) = self.display.timezone.as_deref() {
1940 if tz.trim().is_empty() || tz.parse::<chrono_tz::Tz>().is_err() {
1941 return Err(ConfigError::InvalidDisplayTimezone {
1942 timezone: tz.to_string(),
1943 });
1944 }
1945 }
1946
1947 // Validate [[git_write.allowed]] entries (ADR-108 Amendment): each
1948 // repo must be a non-empty absolute path, and each entry must carry
1949 // at least one branch pattern — an entry with an empty `branches`
1950 // list would silently allowlist a repo for no branch at all, which
1951 // reads as "configured" while behaving identically to "not
1952 // allowlisted"; reject it loudly instead of leaving that trap.
1953 for entry in &self.git_write.allowed {
1954 if entry.repo.trim().is_empty() {
1955 return Err(ConfigError::InvalidGitWriteEntry {
1956 repo: entry.repo.clone(),
1957 reason: "repo must not be empty".to_string(),
1958 });
1959 }
1960 if !Path::new(&entry.repo).is_absolute() {
1961 return Err(ConfigError::InvalidGitWriteEntry {
1962 repo: entry.repo.clone(),
1963 reason: "repo must be an absolute path".to_string(),
1964 });
1965 }
1966 if entry.branches.is_empty() {
1967 return Err(ConfigError::InvalidGitWriteEntry {
1968 repo: entry.repo.clone(),
1969 reason: "branches must not be empty".to_string(),
1970 });
1971 }
1972 if entry.branches.iter().any(|b| b.trim().is_empty()) {
1973 return Err(ConfigError::InvalidGitWriteEntry {
1974 repo: entry.repo.clone(),
1975 reason: "branches entries must not be empty".to_string(),
1976 });
1977 }
1978 // ADR-108 specifies exact name or a SINGLE-star wildcard per
1979 // branch pattern -- a pattern with two or more `*` (e.g. `**`,
1980 // `rel-*-*-final`) is a wider grammar than the ADR authorizes
1981 // and must be rejected at config load, not silently accepted.
1982 if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
1983 return Err(ConfigError::InvalidGitWriteEntry {
1984 repo: entry.repo.clone(),
1985 reason: format!(
1986 "branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
1987 ),
1988 });
1989 }
1990 }
1991
1992 if self.engines.is_empty() {
1993 return Ok(());
1994 }
1995
1996 let mut seen_names = std::collections::HashSet::new();
1997 for engine in &self.engines {
1998 if !seen_names.insert(engine.name.clone()) {
1999 return Err(ConfigError::DuplicateName {
2000 name: engine.name.clone(),
2001 });
2002 }
2003 if parse_embedding_model_alias(&engine.model).is_none() {
2004 return Err(ConfigError::UnknownModel {
2005 name: engine.name.clone(),
2006 model: engine.model.clone(),
2007 });
2008 }
2009 }
2010
2011 let default_count = self.engines.iter().filter(|e| e.default).count();
2012 if default_count != 1 {
2013 return Err(ConfigError::DefaultCount {
2014 found: default_count,
2015 });
2016 }
2017
2018 // Reject non-finite fusion_weight explicitly: NaN doesn't satisfy `w <= 0.0`
2019 // and +inf is unbounded, so neither is caught by the range check alone.
2020 for engine in &self.engines {
2021 if let Some(w) = engine.fusion_weight {
2022 if !w.is_finite() || w <= 0.0 {
2023 return Err(ConfigError::InvalidFusionWeight {
2024 name: engine.name.clone(),
2025 value: w,
2026 });
2027 }
2028 }
2029 }
2030 if let Some(engine) = self
2031 .engines
2032 .iter()
2033 .find(|engine| engine.fusion_weight.is_some())
2034 {
2035 return Err(ConfigError::UnsupportedFusionWeight {
2036 name: engine.name.clone(),
2037 });
2038 }
2039
2040 Ok(())
2041 }
2042
2043 /// Return the engine flagged `default = true`, or `None` if the list is empty.
2044 pub fn default_engine(&self) -> Option<&EngineConfig> {
2045 self.engines.iter().find(|e| e.default)
2046 }
2047}
2048
2049// ---- Env-var fallback ----
2050
2051/// Build an in-memory `KhiveConfig` from the legacy env-var path.
2052///
2053/// Used when no config file is present. Emits `tracing::info!` directing
2054/// operators to migrate to `~/.khive/config.toml`.
2055///
2056/// The primary model (`KHIVE_EMBEDDING_MODEL`) becomes the `default = true`
2057/// engine; additional models become non-default secondary engines. When only
2058/// `KHIVE_ADDITIONAL_EMBEDDING_MODELS` is set, the built-in default model is
2059/// synthesized as the primary — the additional list is additive, never a
2060/// replacement for the primary (khive#1221; matches `RuntimeConfig::default()`,
2061/// which resolves an unset `KHIVE_EMBEDDING_MODEL` to the built-in default).
2062pub fn config_from_env() -> KhiveConfig {
2063 let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
2064 .ok()
2065 .filter(|s| !s.trim().is_empty());
2066 let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
2067 .ok()
2068 .unwrap_or_default();
2069 let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
2070 .into_iter()
2071 .filter(|s| !s.is_empty())
2072 .collect();
2073
2074 if primary_model.is_none() && additional.is_empty() {
2075 return KhiveConfig::default();
2076 }
2077
2078 tracing::info!(
2079 "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
2080 );
2081
2082 config_from_env_parts(primary_model, additional)
2083}
2084
2085/// Pure core of [`config_from_env`], separated so the engine-list derivation
2086/// is testable without mutating process-global environment variables.
2087fn config_from_env_parts(primary_model: Option<String>, additional: Vec<String>) -> KhiveConfig {
2088 let mut engines = Vec::new();
2089
2090 let primary =
2091 primary_model.unwrap_or_else(|| lattice_embed::EmbeddingModel::AllMiniLmL6V2.to_string());
2092 engines.push(EngineConfig {
2093 name: "default".to_string(),
2094 model: primary.clone(),
2095 default: true,
2096 fusion_weight: None,
2097 dims: None,
2098 });
2099
2100 for (i, model) in additional.into_iter().enumerate() {
2101 // The additional list restating the primary is a no-op, not a second engine.
2102 if model.eq_ignore_ascii_case(&primary) {
2103 continue;
2104 }
2105 engines.push(EngineConfig {
2106 name: format!("engine-{}", i + 1),
2107 model,
2108 default: false,
2109 fusion_weight: None,
2110 dims: None,
2111 });
2112 }
2113
2114 KhiveConfig {
2115 engines,
2116 ..KhiveConfig::default()
2117 }
2118}
2119
2120// ---- Tests ----
2121
2122// Kept in-crate (not tests/): exercises private ConfigError variants not part
2123// of the public API.
2124#[cfg(test)]
2125#[path = "engine_config_tests.rs"]
2126mod tests;