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