Skip to main content

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        /// Free-space floor in bytes. Absent: 2% of the volume holding the
578        /// root, at least 5 GiB (ADR-111 Amendment 6); `0` disables it.
579        #[serde(default)]
580        floor_bytes: Option<u64>,
581    },
582    /// S3-compatible blob storage (`S3BlobStore`). `KHIVE_BLOB_ROOT` has no
583    /// effect for this backend. Credentials always come from
584    /// `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN` in the
585    /// process environment, never from this section.
586    S3 {
587        bucket: String,
588        region: String,
589        #[serde(default)]
590        endpoint: Option<String>,
591        #[serde(default)]
592        prefix: Option<String>,
593        #[serde(default)]
594        allow_http: Option<bool>,
595    },
596}
597
598/// `[blob]` pack policy, separate from the `[storage.blob]` backend selector.
599#[derive(Debug, Clone, Deserialize, Default)]
600#[serde(deny_unknown_fields)]
601pub struct BlobSectionConfig {
602    /// Permit server-local file transfers. Absent means disabled.
603    #[serde(default)]
604    pub file_transfers: bool,
605}
606
607/// `[storage]` section in `khive.toml`. Holds storage-layer config not
608/// already covered by `[[backends]]` (ADR-028). Unknown fields are rejected
609/// so an unsupported database selector cannot silently select the default store.
610#[derive(Debug, Clone, Deserialize, Default)]
611#[serde(deny_unknown_fields)]
612pub struct StorageSectionConfig {
613    /// Blob store backend selector (ADR-111 Amendment 2). Absent means
614    /// `FsBlobStore` at the existing root-resolution precedence, unchanged
615    /// from khive#292 -- existing configurations keep behaving exactly as
616    /// they did before this section existed.
617    #[serde(default)]
618    pub blob: Option<BlobConfig>,
619}
620
621/// `[brain]` read policy resolved by the serving process.
622#[derive(Debug, Clone, Deserialize, Serialize, Default)]
623#[serde(deny_unknown_fields)]
624pub struct BrainSectionConfig {
625    /// Actor ids permitted to request fleet-wide `brain.event_counts` reads.
626    #[serde(default)]
627    pub fleet_readers: Vec<String>,
628}
629
630// ---- git-write policy (ADR-108 Amendment) ----
631
632/// One `[[git_write.allowed]]` entry: a repo this operator has declared
633/// trusted for khive-mediated git writes, plus the branches on it a write
634/// verb (`git.commit`/`git.branch`/`git.update_ref`/`git.push`) may target.
635///
636/// ```toml
637/// [[git_write.allowed]]
638/// repo = "/abs/path/repo"
639/// branches = ["feat/*", "fix/*"]
640/// ```
641#[derive(Debug, Clone, Deserialize, Serialize)]
642pub struct GitWriteEntryConfig {
643    /// Absolute local path to the allowlisted repository.
644    pub repo: String,
645    /// Non-empty list of exact branch names or single-`*`-wildcard globs
646    /// this repo entry permits writes against.
647    pub branches: Vec<String>,
648}
649
650/// `[git_write]` section — the closed repo/branch allowlist consulted by
651/// `khive-pack-git`'s write verbs at the handler level (ADR-108 Amendment),
652/// independent of Gate policy. Absent or empty `allowed` is the fail-closed
653/// default: the write verbs report themselves unavailable rather than
654/// defaulting open.
655///
656/// ```toml
657/// [[git_write.allowed]]
658/// repo = "/abs/path/repo"
659/// branches = ["feat/*", "fix/*"]
660/// ```
661#[derive(Debug, Clone, Deserialize, Serialize)]
662pub struct GitWriteSectionConfig {
663    /// Absolute git executable override; absent preserves PATH resolution.
664    #[serde(default)]
665    pub program: Option<PathBuf>,
666    #[serde(default)]
667    pub allowed: Vec<GitWriteEntryConfig>,
668    #[serde(default)]
669    pub actors: BTreeMap<String, GitWriteActorConfig>,
670    #[serde(default)]
671    pub repositories: BTreeMap<String, GitWriteRepositoryConfig>,
672    #[serde(default = "default_git_credential_resolver")]
673    pub credential_resolver: Vec<String>,
674    #[serde(default)]
675    pub contract_faults: bool,
676    #[serde(default)]
677    pub fault: Option<String>,
678}
679
680#[derive(Debug, Clone, Deserialize, Serialize)]
681#[serde(deny_unknown_fields)]
682pub struct GitWriteRepositoryConfig {
683    /// HTTPS platform remote, or an absolute path / file:/// URL with an empty slug.
684    pub remote: String,
685    /// owner/name for HTTPS; empty explicitly opts into credential-free local pushes.
686    pub slug: String,
687    pub visibility: String,
688    /// Merge dispatch refusals for this repository (ADR-182 Amendment 7):
689    /// `opener` refuses a `git.pr_merge` dispatched by the account or actor
690    /// that opened the pull request; `last_pusher` refuses one dispatched by
691    /// the login on the newest push receipt for `expected_head`. Empty (the
692    /// default) refuses neither.
693    #[serde(default)]
694    pub merge_refusals: Vec<String>,
695}
696
697impl GitWriteRepositoryConfig {
698    pub const MERGE_REFUSALS: [&'static str; 2] = ["opener", "last_pusher"];
699
700    /// Whether this repository row lists the named merge refusal.
701    pub fn refuses_merge_by(&self, entry: &str) -> bool {
702        self.merge_refusals.iter().any(|listed| listed == entry)
703    }
704}
705
706#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
707#[serde(deny_unknown_fields)]
708pub struct GitWriteActorConfig {
709    pub name: String,
710    pub email: String,
711    pub credential_ref: String,
712    pub platform_identity: String,
713}
714
715fn default_git_credential_resolver() -> Vec<String> {
716    [
717        "/usr/bin/security",
718        "find-generic-password",
719        "-w",
720        "-s",
721        "{ref}",
722    ]
723    .into_iter()
724    .map(str::to_string)
725    .collect()
726}
727
728impl Default for GitWriteSectionConfig {
729    fn default() -> Self {
730        Self {
731            program: None,
732            allowed: Vec::new(),
733            actors: BTreeMap::new(),
734            repositories: BTreeMap::new(),
735            credential_resolver: default_git_credential_resolver(),
736            contract_faults: false,
737            fault: None,
738        }
739    }
740}
741
742impl GitWriteSectionConfig {
743    pub fn git_program(&self) -> &Path {
744        self.program.as_deref().unwrap_or_else(|| Path::new("git"))
745    }
746
747    pub fn validate_dev_loop(&self) -> Result<(), ConfigError> {
748        let invalid = |key: &str, reason: &str| ConfigError::InvalidGitWriteConfig {
749            key: key.to_string(),
750            reason: reason.to_string(),
751        };
752        if let Some(program) = &self.program {
753            if !program.is_absolute() {
754                return Err(invalid("git_write.program", "must be absolute"));
755            }
756            let metadata = std::fs::metadata(program).map_err(|error| {
757                if error.kind() == std::io::ErrorKind::NotFound {
758                    invalid("git_write.program", "does not exist")
759                } else {
760                    invalid("git_write.program", &format!("is not executable: {error}"))
761                }
762            })?;
763            #[cfg(unix)]
764            let executable = {
765                use std::os::unix::fs::PermissionsExt;
766                metadata.permissions().mode() & 0o111 != 0
767            };
768            #[cfg(windows)]
769            let executable = program
770                .extension()
771                .and_then(|extension| extension.to_str())
772                .is_some_and(|extension| {
773                    extension.eq_ignore_ascii_case("exe") || extension.eq_ignore_ascii_case("com")
774                });
775            #[cfg(not(any(unix, windows)))]
776            let executable = false;
777            if !metadata.is_file() || !executable {
778                return Err(invalid("git_write.program", "is not executable"));
779            }
780        }
781        if self.contract_faults && !cfg!(feature = "contract-faults") {
782            tracing::error!(
783                target: "khive.boot",
784                "[git_write] contract_faults requires the test-only contract-faults build feature"
785            );
786            return Err(invalid(
787                "contract_faults",
788                "requires the test-only contract-faults build feature",
789            ));
790        }
791        if let Some(fault) = &self.fault {
792            if !self.contract_faults {
793                return Err(invalid("fault", "requires contract_faults = true"));
794            }
795            let valid = fault.split_once(':').is_some_and(|(verb, point)| {
796                matches!(verb, "git.push" | "git.pr_merge")
797                    && matches!(
798                        point,
799                        "reply-lost-after-effect" | "audit-fails-after-effect"
800                    )
801            });
802            if !valid {
803                return Err(invalid("fault", "unsupported contract fault selector"));
804            }
805        }
806        for (path, repository) in &self.repositories {
807            let key = format!("repositories.{path}.merge_refusals");
808            let mut seen: Vec<&str> = Vec::new();
809            for entry in &repository.merge_refusals {
810                if !GitWriteRepositoryConfig::MERGE_REFUSALS.contains(&entry.as_str()) {
811                    return Err(invalid(&key, "entries must be opener or last_pusher"));
812                }
813                if seen.contains(&entry.as_str()) {
814                    return Err(invalid(&key, "entries must not repeat"));
815                }
816                seen.push(entry);
817            }
818        }
819        // The default keychain program is Unix-only. Legacy configurations with
820        // no actor mappings cannot invoke it, so they remain loadable elsewhere.
821        if !cfg!(unix)
822            && self.actors.is_empty()
823            && self.credential_resolver == default_git_credential_resolver()
824        {
825            return Ok(());
826        }
827        let argv = &self.credential_resolver;
828        let Some(program) = argv.first() else {
829            return Err(invalid("credential_resolver", "argv must not be empty"));
830        };
831        let program_path = Path::new(program);
832        if !program_path.is_absolute() {
833            return Err(invalid(
834                "credential_resolver",
835                "argv[0] must be an absolute path",
836            ));
837        }
838        let program_name = program_path
839            .file_name()
840            .and_then(|name| name.to_str())
841            .unwrap_or_default()
842            .to_ascii_lowercase();
843        if matches!(
844            program_name.trim_end_matches(".exe"),
845            "sh" | "bash"
846                | "dash"
847                | "zsh"
848                | "ksh"
849                | "fish"
850                | "csh"
851                | "tcsh"
852                | "cmd"
853                | "powershell"
854                | "pwsh"
855                | "env"
856        ) {
857            return Err(invalid(
858                "credential_resolver",
859                "shell or env launcher is not allowed",
860            ));
861        }
862        if argv.iter().any(|arg| arg.chars().any(char::is_control)) {
863            return Err(invalid(
864                "credential_resolver",
865                "argv must not contain control characters",
866            ));
867        }
868        if program.contains(['{', '}'])
869            || argv[1..]
870                .iter()
871                .any(|arg| arg != "{ref}" && arg.contains(['{', '}']))
872        {
873            return Err(invalid(
874                "credential_resolver",
875                "{ref} must be a complete argument and is the only allowed template",
876            ));
877        }
878        if !argv[1..].iter().any(|arg| arg == "{ref}") {
879            return Err(invalid(
880                "credential_resolver",
881                "argv must contain a {ref} argument",
882            ));
883        }
884        for (actor, identity) in &self.actors {
885            if actor.trim().is_empty() || actor.chars().any(char::is_control) {
886                return Err(invalid(
887                    "actors",
888                    "actor labels must be nonempty and contain no control characters",
889                ));
890            }
891            for (field, value) in [
892                ("name", &identity.name),
893                ("email", &identity.email),
894                ("credential_ref", &identity.credential_ref),
895                ("platform_identity", &identity.platform_identity),
896            ] {
897                if value.trim().is_empty() || value.chars().any(char::is_control) {
898                    return Err(invalid(
899                        &format!("actors.{actor}.{field}"),
900                        "must be nonempty and contain no control characters",
901                    ));
902                }
903            }
904            if identity.name.contains(['<', '>']) || identity.email.contains(['<', '>']) {
905                return Err(invalid(
906                    &format!("actors.{actor}"),
907                    "name and email must not contain Git identity delimiters",
908                ));
909            }
910        }
911        Ok(())
912    }
913}
914
915// ---- exec sandbox (ADR-181) ----
916
917/// `[exec.limits]`: per-run resource limits applied to the sandboxed child
918/// and inherited by its descendants (`setrlimit` before exec).
919#[derive(Debug, Clone, Deserialize, Default)]
920pub struct ExecLimitsConfig {
921    #[serde(default)]
922    pub cpu_seconds: Option<u64>,
923    #[serde(default)]
924    pub address_space: Option<u64>,
925    #[serde(default)]
926    pub file_size: Option<u64>,
927    #[serde(default)]
928    pub nproc: Option<u64>,
929}
930
931/// `[exec]` section (ADR-181): where runs materialize, what they may read,
932/// which caller environment keys pass through, which executable paths the
933/// `never` list matches, and the output caps, wall-clock defaults and resource
934/// limits. `never` matches paths, not a program's capabilities (ADR-181 A9).
935///
936/// ```toml
937/// [exec]
938/// root = "/var/lib/khive/exec"
939/// read_roots = ["/opt/toolchains/python3.11"]
940/// env = ["SOURCE_DATE_EPOCH"]
941/// # The never list matches resolved executable paths, not renamed copies.
942/// never = ["/usr/bin/curl"]
943/// max_output_bytes = 1048576
944/// timeout_default_s = 30
945/// timeout_max_s = 600
946/// binary_digest_timeout_s = 10 # 1..=60; independent of run timeout
947/// keep = false
948///
949/// [exec.limits]
950/// cpu_seconds = 60
951/// file_size = 104857600
952/// ```
953#[derive(Debug, Clone, Deserialize, Default)]
954#[serde(deny_unknown_fields)]
955pub struct ExecSectionConfig {
956    #[serde(default)]
957    pub root: Option<String>,
958    #[serde(default)]
959    pub read_roots: Vec<String>,
960    #[serde(default)]
961    pub env: Vec<String>,
962    #[serde(default)]
963    pub never: Vec<String>,
964    #[serde(default)]
965    pub max_output_bytes: Option<u64>,
966    #[serde(default)]
967    pub timeout_default_s: Option<f64>,
968    #[serde(default)]
969    pub timeout_max_s: Option<f64>,
970    /// Stalled-mount guard for hashing an authorized binary (1..=60 seconds).
971    #[serde(default)]
972    pub binary_digest_timeout_s: Option<u64>,
973    #[serde(default)]
974    pub keep: bool,
975    #[serde(default)]
976    pub limits: ExecLimitsConfig,
977}
978
979pub const DEFAULT_EXEC_BINARY_DIGEST_TIMEOUT_S: u64 = 10;
980pub const MAX_EXEC_BINARY_DIGEST_TIMEOUT_S: u64 = 60;
981
982// ---- web fetch/search policy (ADR-175 Amendment 1, carried into ADR-191 D3) ----
983
984/// One `[[web.allowlist]]` entry: an exclusive host the operator has opted
985/// into reachability for `web.fetch`/`web.search`. Presence of ANY entry
986/// makes the allowlist exclusive (ADR-175 A1.2.3); absence leaves the public
987/// internet reachable subject to the other egress rules. Matched by exact,
988/// normalized (lowercase, trailing-dot-stripped) host equality only — no
989/// suffix wildcarding, unlike `[[web.credentials]].hosts` (A1.2.6).
990#[derive(Debug, Clone, Deserialize, Serialize)]
991#[serde(deny_unknown_fields)]
992pub struct WebAllowlistEntry {
993    pub host: String,
994}
995
996/// One `[[web.credentials]]` entry: a named secret, read from the process
997/// environment at request time (never accepted as a verb argument), bound to
998/// the set of hosts it may be presented to. Each `hosts` entry is either an
999/// exact IP-literal address (matched exactly, never as a suffix) or a
1000/// hostname suffix (`example.com` matches `example.com` and any
1001/// `*.example.com` at a DNS label boundary) — ADR-175 A1.2.6.
1002#[derive(Debug, Clone, Deserialize, Serialize)]
1003#[serde(deny_unknown_fields)]
1004pub struct WebCredentialConfig {
1005    /// Name the caller passes as `web.fetch`'s `credential` argument.
1006    pub name: String,
1007    /// Process environment variable holding the secret value.
1008    pub env_var: String,
1009    /// Non-empty set of hosts (exact IP literals or hostname suffixes) this
1010    /// credential may be presented to.
1011    pub hosts: Vec<String>,
1012}
1013
1014/// One canned result inside a `kind = "fixture"` `[[web.search_providers]]`
1015/// entry — deterministic, non-networked search results (demos, offline
1016/// corpora, and the fixture arm of `web.search`'s own test suite).
1017#[derive(Debug, Clone, Deserialize, Serialize)]
1018#[serde(deny_unknown_fields)]
1019pub struct WebFixtureResult {
1020    pub title: String,
1021    pub url: String,
1022    pub snippet: String,
1023}
1024
1025/// One `[[web.search_providers]]` entry (ADR-175 A1.3). The provider is
1026/// operator configuration; `web.search`'s `provider` argument only selects
1027/// among entries declared here by `name`. Closed, tagged on `kind`.
1028#[derive(Debug, Clone, Deserialize, Serialize)]
1029#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
1030pub enum WebSearchProviderConfig {
1031    /// Deterministic canned results — no outbound request.
1032    Fixture {
1033        name: String,
1034        #[serde(default)]
1035        default: bool,
1036        results: Vec<WebFixtureResult>,
1037    },
1038    /// A real HTTP GET search backend. `url_template` must contain the
1039    /// literal substring `{query}`, replaced with the percent-encoded query
1040    /// at request time; `{limit}` is replaced with the effective limit when
1041    /// present. The response body is JSON: an array of `{title, url,
1042    /// snippet}` objects. `api_key_env`, when set, is a process environment
1043    /// variable sent as `Authorization: Bearer <value>`.
1044    Http {
1045        name: String,
1046        #[serde(default)]
1047        default: bool,
1048        url_template: String,
1049        #[serde(default)]
1050        api_key_env: Option<String>,
1051        /// Hosts (exact IP literals or hostname suffixes) `api_key_env`'s
1052        /// value may be presented to, modeled on
1053        /// `[[web.credentials]].hosts`. Required non-empty whenever
1054        /// `api_key_env` is set (`validate` enforces this) — an unscoped key
1055        /// would ride along to whatever host `url_template` resolves to,
1056        /// which defeats the point of scoping it at all.
1057        #[serde(default)]
1058        hosts: Vec<String>,
1059    },
1060}
1061
1062impl WebSearchProviderConfig {
1063    pub fn name(&self) -> &str {
1064        match self {
1065            WebSearchProviderConfig::Fixture { name, .. } => name,
1066            WebSearchProviderConfig::Http { name, .. } => name,
1067        }
1068    }
1069
1070    pub fn is_default(&self) -> bool {
1071        match self {
1072            WebSearchProviderConfig::Fixture { default, .. } => *default,
1073            WebSearchProviderConfig::Http { default, .. } => *default,
1074        }
1075    }
1076}
1077
1078/// `[web]` section (ADR-175 Amendment 1, ADR-191 D3): operator policy for
1079/// `web.fetch` and `web.search` — ceilings, the address allowlist, credential
1080/// host-set bindings, and configured search providers.
1081///
1082/// No generic per-pack settings map exists in this file today (`PackConfig`
1083/// carries only `backend`/`no_embed`, both storage-routing concerns) so this
1084/// follows the established precedent for a pack needing rich operator policy:
1085/// a dedicated top-level section threaded through `RuntimeConfig`, the same
1086/// shape as `[exec]` and `[git_write]`.
1087///
1088/// ```toml
1089/// [web]
1090/// timeout_default_s = 30
1091/// timeout_max_s = 120
1092/// max_bytes_default = 5242880
1093/// max_bytes_max = 52428800
1094/// search_limit_default = 10
1095/// search_limit_max = 50
1096///
1097/// [[web.allowlist]]
1098/// host = "example.com"
1099///
1100/// [[web.credentials]]
1101/// name = "example-token"
1102/// env_var = "EXAMPLE_API_TOKEN"
1103/// hosts = ["example.com"]
1104///
1105/// [[web.search_providers]]
1106/// kind = "fixture"
1107/// name = "demo"
1108/// default = true
1109/// results = [{ title = "Example", url = "https://example.com", snippet = "..." }]
1110/// ```
1111#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1112#[serde(deny_unknown_fields)]
1113pub struct WebSectionConfig {
1114    #[serde(default)]
1115    pub timeout_default_s: Option<u64>,
1116    #[serde(default)]
1117    pub timeout_max_s: Option<u64>,
1118    #[serde(default)]
1119    pub max_bytes_default: Option<u64>,
1120    #[serde(default)]
1121    pub max_bytes_max: Option<u64>,
1122    #[serde(default)]
1123    pub search_limit_default: Option<u32>,
1124    #[serde(default)]
1125    pub search_limit_max: Option<u32>,
1126    #[serde(default)]
1127    pub allowlist: Vec<WebAllowlistEntry>,
1128    #[serde(default)]
1129    pub credentials: Vec<WebCredentialConfig>,
1130    #[serde(default)]
1131    pub search_providers: Vec<WebSearchProviderConfig>,
1132    /// Directories `web.ingest`'s disk mode may read from, modeled on
1133    /// `[exec] read_roots`. Absent or empty fails closed: disk ingest is
1134    /// refused entirely until the operator names at least one root.
1135    #[serde(default)]
1136    pub read_roots: Vec<String>,
1137}
1138
1139/// Effective operator bounds shared by file validation and programmatic web dispatch.
1140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1141pub struct WebCeilings {
1142    pub timeout_default_s: u64,
1143    pub timeout_max_s: u64,
1144    pub max_bytes_default: u64,
1145    pub max_bytes_max: u64,
1146    pub search_limit_default: u32,
1147    pub search_limit_max: u32,
1148}
1149
1150impl Default for WebCeilings {
1151    fn default() -> Self {
1152        Self {
1153            timeout_default_s: 30,
1154            timeout_max_s: 120,
1155            max_bytes_default: 5 * 1024 * 1024,
1156            max_bytes_max: 50 * 1024 * 1024,
1157            search_limit_default: 10,
1158            search_limit_max: 50,
1159        }
1160    }
1161}
1162
1163impl WebSectionConfig {
1164    pub fn resolved_ceilings(&self) -> Result<WebCeilings, ConfigError> {
1165        let defaults = WebCeilings::default();
1166        let bounds = WebCeilings {
1167            timeout_default_s: self.timeout_default_s.unwrap_or(defaults.timeout_default_s),
1168            timeout_max_s: self.timeout_max_s.unwrap_or(defaults.timeout_max_s),
1169            max_bytes_default: self.max_bytes_default.unwrap_or(defaults.max_bytes_default),
1170            max_bytes_max: self.max_bytes_max.unwrap_or(defaults.max_bytes_max),
1171            search_limit_default: self
1172                .search_limit_default
1173                .unwrap_or(defaults.search_limit_default),
1174            search_limit_max: self.search_limit_max.unwrap_or(defaults.search_limit_max),
1175        };
1176        for (key, default, maximum, maximum_key) in [
1177            (
1178                "timeout_default_s",
1179                bounds.timeout_default_s,
1180                bounds.timeout_max_s,
1181                "timeout_max_s",
1182            ),
1183            (
1184                "max_bytes_default",
1185                bounds.max_bytes_default,
1186                bounds.max_bytes_max,
1187                "max_bytes_max",
1188            ),
1189            (
1190                "search_limit_default",
1191                u64::from(bounds.search_limit_default),
1192                u64::from(bounds.search_limit_max),
1193                "search_limit_max",
1194            ),
1195        ] {
1196            if default == 0 || default > maximum {
1197                return Err(ConfigError::InvalidWebConfig {
1198                    key: key.into(),
1199                    reason: format!(
1200                        "resolved default must be positive and not exceed {maximum_key}={maximum}"
1201                    ),
1202                });
1203            }
1204        }
1205        if std::time::Instant::now()
1206            .checked_add(std::time::Duration::from_secs(bounds.timeout_max_s))
1207            .is_none()
1208        {
1209            return Err(ConfigError::InvalidWebConfig {
1210                key: "timeout_max_s".into(),
1211                reason: "cannot be represented as a request deadline".into(),
1212            });
1213        }
1214        Ok(bounds)
1215    }
1216
1217    pub fn validate(&self) -> Result<(), ConfigError> {
1218        self.resolved_ceilings()?;
1219        let invalid = |key: &str, reason: &str| ConfigError::InvalidWebConfig {
1220            key: key.to_string(),
1221            reason: reason.to_string(),
1222        };
1223        let mut seen_hosts = std::collections::HashSet::new();
1224        for entry in &self.allowlist {
1225            let normalized = entry.host.trim().trim_end_matches('.').to_ascii_lowercase();
1226            if normalized.is_empty() {
1227                return Err(invalid("allowlist.host", "must not be empty"));
1228            }
1229            if !seen_hosts.insert(normalized) {
1230                return Err(invalid("allowlist.host", "duplicate host entry"));
1231            }
1232        }
1233        let mut seen_credentials = std::collections::HashSet::new();
1234        for credential in &self.credentials {
1235            if credential.name.trim().is_empty() {
1236                return Err(invalid("credentials.name", "must not be empty"));
1237            }
1238            if !seen_credentials.insert(credential.name.clone()) {
1239                return Err(invalid("credentials.name", "duplicate credential name"));
1240            }
1241            if credential.env_var.trim().is_empty() {
1242                return Err(invalid("credentials.env_var", "must not be empty"));
1243            }
1244            if credential.hosts.is_empty() {
1245                return Err(invalid(
1246                    "credentials.hosts",
1247                    "must name at least one host or suffix",
1248                ));
1249            }
1250        }
1251        let mut seen_providers = std::collections::HashSet::new();
1252        let mut default_count = 0;
1253        for provider in &self.search_providers {
1254            let name = provider.name();
1255            if name.trim().is_empty() {
1256                return Err(invalid("search_providers.name", "must not be empty"));
1257            }
1258            if !seen_providers.insert(name.to_string()) {
1259                return Err(invalid("search_providers.name", "duplicate provider name"));
1260            }
1261            if provider.is_default() {
1262                default_count += 1;
1263            }
1264            if let WebSearchProviderConfig::Http {
1265                url_template,
1266                api_key_env,
1267                hosts,
1268                ..
1269            } = provider
1270            {
1271                if !url_template.contains("{query}") {
1272                    return Err(invalid(
1273                        "search_providers.url_template",
1274                        "must contain the literal substring {query}",
1275                    ));
1276                }
1277                if api_key_env.is_some() && hosts.is_empty() {
1278                    return Err(invalid(
1279                        "search_providers.hosts",
1280                        "an api_key_env-bearing provider must name at least one host or suffix",
1281                    ));
1282                }
1283            }
1284        }
1285        if default_count > 1 {
1286            return Err(invalid(
1287                "search_providers",
1288                "at most one provider may set default = true",
1289            ));
1290        }
1291        Ok(())
1292    }
1293}
1294
1295/// Top-level khive configuration loaded from `khive.toml` or `config.toml`.
1296///
1297/// Sections consumed today:
1298/// - `[[engines]]`: embedding engine declarations
1299/// - `[actor]`: default namespace / identity (OSS actor model)
1300/// - `[gate]`: built-in caller enrollment
1301/// - `[runtime]`: runtime knobs (pack selection, brain profile, output format)
1302/// - `[brain]`: actor read policy
1303/// - `[telemetry]`: stream and channel carrier policy
1304/// - `[[backends]]`: storage backend declarations (ADR-028)
1305/// - `[packs.<name>]`: per-pack backend assignments (ADR-028)
1306/// - `[display]`: rendering timezone (ADR-169)
1307/// - `[blob]`: server-local file-transfer opt-in
1308///
1309/// Unknown top-level keys are silently ignored by serde for forward
1310/// compatibility. The `[actor]`, `[gate]`, `[brain]`, `[blob]`, and `[telemetry]` tables are closed
1311/// with `deny_unknown_fields` so a misspelled policy key always fails startup.
1312/// `[storage]`, `[[backends]]` entries and `[exec]` are also closed so unknown
1313/// destination keys cannot be silently dropped, as are `[web]` and `[[mounts]]` entries.
1314#[derive(Debug, Clone, Deserialize, Default)]
1315pub struct KhiveConfig {
1316    /// Read by `credentials::read_tables` at load, never by this derive.
1317    #[serde(skip)]
1318    pub credentials: Vec<crate::credentials::CredentialConfig>,
1319
1320    #[serde(skip)]
1321    pub visibility_receipts: Option<crate::credentials::VisibilityReceiptConfig>,
1322
1323    #[serde(default)]
1324    pub mounts: Vec<crate::mount_config::MountConfig>,
1325
1326    /// Typed only so a top-level `db` key can be rejected loudly by
1327    /// [`KhiveConfig::validate`] instead of being silently ignored as an
1328    /// unknown key. Not a supported config-file storage selector: single-file
1329    /// database selection is `--db`/`KHIVE_DB`, and storage topology is
1330    /// `[[backends]].path`.
1331    #[serde(default)]
1332    pub db: Option<String>,
1333
1334    /// Embedding engine declarations.
1335    #[serde(default)]
1336    pub engines: Vec<EngineConfig>,
1337
1338    /// Distinguishes a file's explicit `engines = []` from an omitted engine list.
1339    /// Use [`Self::load`] for file input; direct deserialization does not track
1340    /// presence or perform legacy engine conversion.
1341    #[serde(skip)]
1342    pub engines_declared: bool,
1343
1344    /// Default actor identity for this khive instance.
1345    ///
1346    /// When present, `actor.id` feeds configuration identity and gate/attribution
1347    /// policy input.  A non-`'local'` `actor.id` is folded into the default READ
1348    /// visible-set at config load (ADR-007 Rev 4 Rule 3b) — it widens what default
1349    /// multi-record reads return, but never routes writes or sets `default_namespace`.
1350    /// Cloud model derives actor identity from an authenticated token.
1351    #[serde(default)]
1352    pub actor: ActorConfig,
1353
1354    /// Optional caller-enrollment policy. A present, even empty, table is an
1355    /// explicit fail-closed policy; an absent table preserves the runtime's
1356    /// existing gate.
1357    #[serde(default)]
1358    pub gate: Option<GateSectionConfig>,
1359
1360    /// Runtime knobs: namespace overrides, brain profile, etc.
1361    #[serde(default)]
1362    pub runtime: RuntimeSectionConfig,
1363
1364    /// Named storage backends (ADR-028).
1365    ///
1366    /// When absent or empty, a single implicit `main` backend is used and all
1367    /// packs share it — identical to pre-ADR-028 behavior.
1368    #[serde(default)]
1369    pub backends: Vec<BackendConfig>,
1370
1371    /// Per-pack backend assignments (ADR-028).
1372    ///
1373    /// Maps pack name to backend name. Packs absent from this map fall back to
1374    /// the `main` backend. Validated at load time: every referenced backend name
1375    /// must appear in `backends`.
1376    #[serde(default)]
1377    pub packs: std::collections::HashMap<String, PackConfig>,
1378
1379    /// Actor read policy. An absent or empty list grants no fleet-wide reads.
1380    #[serde(default)]
1381    pub brain: BrainSectionConfig,
1382
1383    /// Git-write policy allowlist (ADR-108 Amendment). Absent or empty
1384    /// `allowed` fails closed — `khive-pack-git`'s write verbs are
1385    /// unavailable until this section is populated.
1386    #[serde(default)]
1387    pub git_write: GitWriteSectionConfig,
1388
1389    /// Server-local file-transfer opt-in. Other blob verbs are unaffected.
1390    #[serde(default)]
1391    pub blob: BlobSectionConfig,
1392
1393    /// Storage-layer config not covered by `[[backends]]` (ADR-111
1394    /// Amendment 2: `[storage.blob]`'s `fs`/`s3` selector).
1395    #[serde(default)]
1396    pub storage: StorageSectionConfig,
1397
1398    /// Exec sandbox section (ADR-181). Absent means no runs: the exec pack
1399    /// refuses every `exec.run` until `[exec] read_roots` names a toolchain.
1400    #[serde(default)]
1401    pub exec: ExecSectionConfig,
1402
1403    /// Stream and channel carrier policy. Unclassified kinds default to ephemeral.
1404    #[serde(default)]
1405    pub telemetry: crate::telemetry_config::TelemetryConfig,
1406
1407    /// Rendering timezone configuration (ADR-169). Absent `timezone` resolves
1408    /// to the host's local zone at [`RuntimeConfig`](crate::RuntimeConfig)
1409    /// construction time.
1410    #[serde(default)]
1411    pub display: DisplaySectionConfig,
1412
1413    /// `web.fetch`/`web.search` operator policy (ADR-175 Amendment 1).
1414    /// Absent is the fail-closed default for search (no provider configured)
1415    /// and the permissive-subject-to-address-rules default for fetch (no
1416    /// allowlist configured).
1417    #[serde(default)]
1418    pub web: WebSectionConfig,
1419}
1420
1421/// `[runtime]` section in `khive.toml`.
1422///
1423/// Carries runtime knobs resolved during process construction. Most mirror a
1424/// CLI flag / environment tier; field documentation calls out exceptions.
1425/// All fields are optional and preserve their already-resolved base value when
1426/// absent.
1427#[derive(Debug, Clone, Deserialize, Default)]
1428pub struct RuntimeSectionConfig {
1429    /// Packs to load when neither `--pack` nor `KHIVE_PACKS` selects them.
1430    /// An absent or empty list preserves the built-in production default.
1431    #[serde(default)]
1432    pub packs: Option<Vec<String>>,
1433
1434    /// Brain profile ID to use for `memory.feedback` / `knowledge.feedback`
1435    /// and recall-time score boosting (ADR-035 §Brain profile configuration).
1436    ///
1437    /// Mirrors `--brain-profile` / `KHIVE_BRAIN_PROFILE`. When absent, the
1438    /// namespace-bound profile (via `brain.resolve`) is tried, then the
1439    /// global tuning prior is used as the final fallback.
1440    #[serde(default)]
1441    pub brain_profile: Option<String>,
1442
1443    /// Default output serialization format (ADR-078).
1444    ///
1445    /// Mirrors `--output-format` / `KHIVE_OUTPUT_FORMAT`. Precedence (highest to lowest):
1446    /// per-request `format` field → `KHIVE_OUTPUT_FORMAT` → this field → builtin `json`.
1447    ///
1448    /// Accepted values: `"json"` (default), `"auto"`, `"table"`.
1449    #[serde(default)]
1450    pub default_output_format: Option<OutputFormat>,
1451
1452    /// Aggregate process-local admission budget for digest-verified blob
1453    /// hydration (ADR-160 D3), in raw bytes.
1454    ///
1455    /// This knob has no environment-variable counterpart. When absent, the
1456    /// resolved runtime keeps its built-in 256 MiB default (or a value supplied
1457    /// directly through `RuntimeConfig`).
1458    #[serde(default)]
1459    pub blob_hydration_bytes: Option<u64>,
1460}
1461
1462/// `[display]` section in `khive.toml` — the timezone khive anchors date-only
1463/// input to (ADR-169 Implementation step 1).
1464///
1465/// ```toml
1466/// [display]
1467/// timezone = "America/New_York"
1468/// ```
1469#[derive(Debug, Clone, Deserialize, Default)]
1470pub struct DisplaySectionConfig {
1471    /// IANA zone name (e.g. `"America/New_York"`, `"Asia/Tokyo"`, `"UTC"`).
1472    /// Validated at load time against `chrono_tz::Tz`'s zone table — an
1473    /// unrecognized name is a startup error, not a silent fallback. Absent →
1474    /// the host's local zone, resolved once via `iana-time-zone` and falling
1475    /// back to UTC when the host zone cannot be determined.
1476    #[serde(default)]
1477    pub timezone: Option<String>,
1478}
1479
1480impl KhiveConfig {
1481    /// Load and validate a `KhiveConfig` from an explicit path.
1482    ///
1483    /// Search order:
1484    /// 1. `path` argument (explicit override — e.g. from `--config` / `KHIVE_CONFIG`)
1485    /// 2. `./.khive/config.toml` (project-local config, relative to the MCP server cwd)
1486    ///
1487    /// The project-local default collocates config with the `khive-test.db` that already
1488    /// lives under `.khive/` in each project directory. `~/.khive/config.toml` is searched
1489    /// by [`KhiveConfig::load_with_home_fallback`] when the project-local file is absent.
1490    ///
1491    /// If the resolved file does **not exist**, returns `Ok(None)`.
1492    /// A missing config is not an error — callers fall back to the env-var path.
1493    ///
1494    /// If the file exists but cannot be parsed, returns a `ConfigError`.
1495    /// After parsing, `validate()` runs and any logical errors are returned.
1496    pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
1497        let resolved = match path {
1498            Some(p) => p.to_path_buf(),
1499            None => PathBuf::from(".khive/config.toml"),
1500        };
1501
1502        if !resolved.exists() {
1503            return Ok(None);
1504        }
1505
1506        // Diagnostics name the canonical path so an error is actionable from
1507        // any cwd; resolution keeps using `resolved` as given.
1508        let diagnostic_path = std::fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1509        let raw = std::fs::read_to_string(&resolved)
1510            .map_err(|source| ConfigError::from(source).in_file(&diagnostic_path))?;
1511        let mut document: toml::Value =
1512            toml::from_str(&raw).map_err(|source| ConfigError::Parse {
1513                path: diagnostic_path.clone(),
1514                source,
1515            })?;
1516        let engines_declared = peers::normalize_engine_input(&mut document, &diagnostic_path)
1517            .map_err(|error| error.in_file(&diagnostic_path))?;
1518        let mut cfg: KhiveConfig = document.try_into().map_err(|source| ConfigError::Parse {
1519            path: diagnostic_path.clone(),
1520            source,
1521        })?;
1522        cfg.engines_declared = engines_declared;
1523        crate::credentials::read_tables(&raw, &mut cfg)
1524            .map_err(|error| ConfigError::from(error).in_file(&diagnostic_path))?;
1525        cfg.validate()
1526            .map_err(|error| error.in_file(&diagnostic_path))?;
1527        for engine in &mut cfg.engines {
1528            engine.name = canonical_engine_name(&engine.name);
1529        }
1530        Ok(Some(cfg))
1531    }
1532
1533    /// Load config with the full resolution order:
1534    ///
1535    /// 1. Explicit `path` (from `--config` / `KHIVE_CONFIG`)
1536    /// 2. `./khive.toml` (project-local, project root)
1537    /// 3. `<db-dir>/config.toml` (project-local, anchored to the resolved database's
1538    ///    own directory — see `project_config_anchor_dir`)
1539    /// 4. `~/.khive/config.toml` (user-global)
1540    ///
1541    /// Returns the first file found, or `Ok(None)` when none exist.
1542    /// Parse errors are propagated immediately — a malformed config is always
1543    /// an error regardless of which tier it came from.
1544    ///
1545    /// The explicit tier (1) is stricter than the discovery tiers: a `path`
1546    /// that names a file which does not exist returns
1547    /// [`ConfigError::ExplicitConfigMissing`] instead of falling through to
1548    /// tiers 2-4 — an operator-selected config that is missing is the same
1549    /// class of mistake as one that is malformed, and silently discovering a
1550    /// different file would boot against a config the operator did not select
1551    /// (ADR-035).
1552    ///
1553    /// `db_path` should be the same database path the caller is about to open
1554    /// (or has already resolved). Passing it makes tier 3 resolve identically
1555    /// for any two processes that target the same database, regardless of
1556    /// their process working directory — this is what lets a thin client and
1557    /// a warm daemon serving the same database agree on one config file. Pass
1558    /// `None` when no database path is known yet; tier 3 then falls back to
1559    /// the process cwd, matching the pre-existing behavior.
1560    pub fn load_with_home_fallback(
1561        path: Option<&Path>,
1562        db_path: Option<&Path>,
1563    ) -> Result<Option<Self>, ConfigError> {
1564        Ok(Self::load_with_home_fallback_and_source(path, db_path)?.map(|(config, _)| config))
1565    }
1566
1567    /// Load config with the full resolution order and retain the exact file
1568    /// that supplied it.
1569    ///
1570    /// This is the diagnostic-preserving form of
1571    /// [`KhiveConfig::load_with_home_fallback`]. Runtime callers that need to
1572    /// tell an operator which selected file must be edited should use this
1573    /// method instead of reconstructing the discovery order independently.
1574    pub fn load_with_home_fallback_and_source(
1575        path: Option<&Path>,
1576        db_path: Option<&Path>,
1577    ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1578        // Tier 1: explicit path (highest priority). An explicit selection
1579        // naming a MISSING file fails loud here — once, at the loader, for
1580        // every entry point — instead of silently falling through to the
1581        // discovery tiers: a mistyped path would otherwise boot against a
1582        // config the operator did not select (ADR-035: an entry point must
1583        // not document an explicit tier while silently falling back to
1584        // discovery). Tiers 2-4 keep their tolerant contract.
1585        if let Some(p) = path {
1586            if !p.exists() {
1587                return Err(ConfigError::ExplicitConfigMissing {
1588                    path: p.to_path_buf(),
1589                });
1590            }
1591            return Ok(Self::load(Some(p))?.map(|config| (config, Self::diagnostic_config_path(p))));
1592        }
1593
1594        // Tiers 2-4: search project root, db-anchored hidden dir, user-global.
1595        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1596        let home_root = std::env::var_os("HOME").map(PathBuf::from);
1597        Self::load_with_roots_and_source(&project_root, home_root.as_deref(), db_path)
1598    }
1599
1600    /// Testable inner search: tiers 2-4, given explicit roots instead of
1601    /// reading `cwd` and `HOME` from process state.
1602    ///
1603    /// - Tier 2: `<project_root>/khive.toml` (still cwd-anchored — unchanged)
1604    /// - Tier 3: `<db_dir>/config.toml`, anchored to `db_path` rather than
1605    ///   `project_root` (see `project_config_anchor_dir`); falls back to
1606    ///   `<project_root>/.khive/config.toml` when `db_path` is `None`
1607    /// - Tier 4: `<home_root>/.khive/config.toml` (skipped when `None`)
1608    #[cfg(test)]
1609    pub(crate) fn load_with_roots(
1610        project_root: &Path,
1611        home_root: Option<&Path>,
1612        db_path: Option<&Path>,
1613    ) -> Result<Option<Self>, ConfigError> {
1614        Ok(
1615            Self::load_with_roots_and_source(project_root, home_root, db_path)?
1616                .map(|(config, _)| config),
1617        )
1618    }
1619
1620    fn load_with_roots_and_source(
1621        project_root: &Path,
1622        home_root: Option<&Path>,
1623        db_path: Option<&Path>,
1624    ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1625        // Tier 2: project root khive.toml.
1626        let tier2 = project_root.join("khive.toml");
1627        if tier2.exists() {
1628            return Ok(Self::load(Some(&tier2))?
1629                .map(|config| (config, Self::diagnostic_config_path(&tier2))));
1630        }
1631
1632        // Tier 3: project-local hidden dir, anchored to the resolved database's
1633        // own directory instead of the process cwd.
1634        let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
1635        if tier3.exists() {
1636            return Ok(Self::load(Some(&tier3))?
1637                .map(|config| (config, Self::diagnostic_config_path(&tier3))));
1638        }
1639
1640        // Tier 4: user-global ~/.khive/config.toml.
1641        if let Some(home) = home_root {
1642            let tier4 = home.join(".khive/config.toml");
1643            if tier4.exists() {
1644                return Ok(Self::load(Some(&tier4))?
1645                    .map(|config| (config, Self::diagnostic_config_path(&tier4))));
1646            }
1647        }
1648
1649        Ok(None)
1650    }
1651
1652    fn diagnostic_config_path(path: &Path) -> PathBuf {
1653        std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1654    }
1655
1656    /// Resolve the directory searched for the tier-3 project-local config file.
1657    ///
1658    /// Anchored to the directory containing the resolved database file, not the
1659    /// process cwd: two processes at different working directories that open the
1660    /// same database agree on this directory, which is what keeps their
1661    /// `config_id` fingerprints in sync (a client and a warm daemon serving the
1662    /// same database must resolve identical config so the daemon accepts the
1663    /// client's forwarded requests instead of rejecting them on a config
1664    /// mismatch).
1665    ///
1666    /// `db_path` is canonicalized first so symlinks/relative components collapse
1667    /// to the same absolute directory regardless of caller cwd. The database file
1668    /// may not exist yet (first run before anything has been written) — in that
1669    /// case canonicalization fails and the path is absolutized against
1670    /// `project_root` instead (or used as-is if already absolute); this must
1671    /// never panic, it is the expected cold-start case.
1672    ///
1673    /// If `db_dir` (the resolved database's parent directory) is itself named
1674    /// `.khive`, the config lives directly inside it (`<db_dir>/config.toml`) —
1675    /// this is the common case where the database is `<root>/.khive/khive.db`.
1676    /// Otherwise the config lives in a `.khive` subdirectory of `db_dir`.
1677    ///
1678    /// `db_path == None` (e.g. an in-memory database, or no database path known
1679    /// yet) falls back to `<project_root>/.khive`, preserving the pre-existing
1680    /// cwd-anchored behavior for callers with no database to anchor on.
1681    fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
1682        let Some(db_path) = db_path else {
1683            return project_root.join(".khive");
1684        };
1685
1686        let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
1687            if db_path.is_absolute() {
1688                db_path.to_path_buf()
1689            } else {
1690                project_root.join(db_path)
1691            }
1692        });
1693
1694        let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
1695
1696        if db_dir.file_name().is_some_and(|name| name == ".khive") {
1697            db_dir
1698        } else {
1699            db_dir.join(".khive")
1700        }
1701    }
1702
1703    /// Validate the parsed config for logical consistency.
1704    ///
1705    /// Checks:
1706    /// Engine names and storage keys are unique; dimensions and weights are
1707    /// positive. Non-unit weights remain refused until retrieval applies them.
1708    pub fn validate(&self) -> Result<(), ConfigError> {
1709        crate::mount_config::validate_mounts(&self.mounts)?;
1710        self.git_write.validate_dev_loop()?;
1711        self.telemetry.validate()?;
1712        self.web.validate()?;
1713        crate::credentials::CredentialConfig::validate_all(&self.credentials)?;
1714        if let Some(receipts) = &self.visibility_receipts {
1715            receipts.validate(&self.credentials)?;
1716        }
1717
1718        // Reject a top-level `db` key loudly instead of letting serde's
1719        // forward-compatible unknown-key tolerance silently swallow it: a
1720        // config author expecting `db=` to select the database would
1721        // otherwise get silent divergence from `--db`/`KHIVE_DB`.
1722        if let Some(value) = self.db.as_deref() {
1723            if !value.is_empty() {
1724                return Err(ConfigError::UnsupportedTopLevelDb {
1725                    value: value.to_string(),
1726                });
1727            }
1728        }
1729
1730        // ADR-181 resource limits: macOS returns EINVAL for RLIMIT_AS and
1731        // RLIMIT_DATA, and RLIMIT_NPROC counts every process of the uid, so
1732        // neither can bound one run. Refuse loudly instead of pretending.
1733        if cfg!(target_os = "macos") {
1734            if self.exec.limits.address_space.is_some() {
1735                return Err(ConfigError::InvalidExecConfig {
1736                    key: "limits.address_space".to_string(),
1737                    reason: "unsupported_on_platform: macOS does not enforce an address-space rlimit per process".to_string(),
1738                });
1739            }
1740            if self.exec.limits.nproc.is_some() {
1741                return Err(ConfigError::InvalidExecConfig {
1742                    key: "limits.nproc".to_string(),
1743                    reason: "unsupported_on_platform: RLIMIT_NPROC counts every process of the uid, not one run".to_string(),
1744                });
1745            }
1746        }
1747        // These defaults must agree with the exec pack's resolved defaults.
1748        // Validate the effective pair, including one-sided overrides, before
1749        // Duration conversion or a deadline can panic in exec.run.
1750        let default_timeout = self.exec.timeout_default_s.unwrap_or(30.0);
1751        let maximum_timeout = self.exec.timeout_max_s.unwrap_or(600.0);
1752        for (key, value) in [
1753            ("timeout_default_s", default_timeout),
1754            ("timeout_max_s", maximum_timeout),
1755        ] {
1756            let duration = value
1757                .is_finite()
1758                .then(|| std::time::Duration::try_from_secs_f64(value).ok())
1759                .flatten()
1760                .filter(|duration| !duration.is_zero());
1761            if duration
1762                .is_none_or(|duration| std::time::Instant::now().checked_add(duration).is_none())
1763            {
1764                return Err(ConfigError::InvalidExecConfig {
1765                    key: key.to_string(),
1766                    reason: "must be positive, finite, and representable as a deadline".to_string(),
1767                });
1768            }
1769        }
1770        if default_timeout > maximum_timeout {
1771            return Err(ConfigError::InvalidExecConfig {
1772                key: "timeout_default_s".to_string(),
1773                reason: format!(
1774                    "default {default_timeout} exceeds timeout_max_s {maximum_timeout}"
1775                ),
1776            });
1777        }
1778        let binary_digest_timeout = self
1779            .exec
1780            .binary_digest_timeout_s
1781            .unwrap_or(DEFAULT_EXEC_BINARY_DIGEST_TIMEOUT_S);
1782        if !(1..=MAX_EXEC_BINARY_DIGEST_TIMEOUT_S).contains(&binary_digest_timeout) {
1783            return Err(ConfigError::InvalidExecConfig {
1784                key: "binary_digest_timeout_s".to_string(),
1785                reason: format!("must be between 1 and {MAX_EXEC_BINARY_DIGEST_TIMEOUT_S} seconds"),
1786            });
1787        }
1788
1789        if let Some(value) = self.runtime.blob_hydration_bytes {
1790            let min = khive_storage::MAX_BLOB_WHOLE_BYTES;
1791            let max = tokio::sync::Semaphore::MAX_PERMITS as u64;
1792            if value < min || value > max {
1793                return Err(ConfigError::InvalidBlobHydrationBytes { value, min, max });
1794            }
1795        }
1796
1797        // Validate actor.id when present — an invalid namespace is a startup error,
1798        // not a silent fallback.
1799        if let Some(id) = self.actor.id.as_deref() {
1800            if id.is_empty() {
1801                return Err(ConfigError::InvalidActorId {
1802                    id: id.to_string(),
1803                    reason: "actor.id must not be empty; remove the key or provide a value"
1804                        .to_string(),
1805                });
1806            }
1807            Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
1808                id: id.to_string(),
1809                reason: e.to_string(),
1810            })?;
1811        }
1812
1813        self.actor
1814            .mailbox_gate(std::sync::Arc::new(khive_gate::AllowAllGate))
1815            .map_err(|error| ConfigError::InvalidMailboxReaders {
1816                reason: error.to_string(),
1817            })?;
1818
1819        if let Some(ref vis) = self.actor.visible_namespaces {
1820            for ns_str in vis {
1821                if ns_str.is_empty() {
1822                    return Err(ConfigError::InvalidActorId {
1823                        id: ns_str.clone(),
1824                        reason: "visible_namespaces entries must not be empty".to_string(),
1825                    });
1826                }
1827                Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1828                    id: ns_str.clone(),
1829                    reason: format!("invalid visible namespace: {e}"),
1830                })?;
1831            }
1832        }
1833
1834        if let Some(gate) = &self.gate {
1835            khive_gate::CallerEnrollmentGate::validate_write_denials(&gate.deny_writes_for)
1836                .map_err(|error| ConfigError::InvalidWriteDenyPatterns {
1837                    reason: error.to_string(),
1838                })?;
1839            for id in &gate.granted_actors {
1840                if id.is_empty() {
1841                    return Err(ConfigError::InvalidGrantedActorId {
1842                        id: id.clone(),
1843                        reason: "actor ids must not be empty".to_string(),
1844                    });
1845                }
1846                Namespace::parse(id).map_err(|error| ConfigError::InvalidGrantedActorId {
1847                    id: id.clone(),
1848                    reason: error.to_string(),
1849                })?;
1850            }
1851        }
1852
1853        // Validate actor.allowed_outbound_namespaces (fail-closed at startup on malformed entry).
1854        for ns_str in &self.actor.allowed_outbound_namespaces {
1855            if ns_str.is_empty() {
1856                return Err(ConfigError::InvalidActorId {
1857                    id: ns_str.clone(),
1858                    reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
1859                });
1860            }
1861            Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1862                id: ns_str.clone(),
1863                reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
1864            })?;
1865        }
1866
1867        // Backend names must be unique.
1868        if !self.backends.is_empty() {
1869            let mut seen_backends = std::collections::HashSet::new();
1870            for backend in &self.backends {
1871                BackendId::parse(&backend.name).map_err(|error| {
1872                    ConfigError::InvalidBackendName {
1873                        name: backend.name.clone(),
1874                        reason: error.to_string(),
1875                    }
1876                })?;
1877                if backend
1878                    .served_kinds
1879                    .as_ref()
1880                    .is_some_and(BTreeSet::is_empty)
1881                {
1882                    return Err(ConfigError::EmptyBackendServedKinds {
1883                        name: backend.name.clone(),
1884                    });
1885                }
1886                if !seen_backends.insert(backend.name.clone()) {
1887                    return Err(ConfigError::DuplicateBackendName {
1888                        name: backend.name.clone(),
1889                    });
1890                }
1891
1892                // The field's static invariants are checked when the config
1893                // file loads. The host resolves the environment fallback once
1894                // for every effective backend before forwarding or opening it.
1895                if backend.wal_ceiling_bytes.is_some() {
1896                    resolve_wal_ceiling(
1897                        backend.wal_ceiling_bytes,
1898                        None,
1899                        &backend.name,
1900                        backend.kind.clone(),
1901                        backend
1902                            .journal_mode
1903                            .as_deref()
1904                            .is_none_or(|mode| mode.eq_ignore_ascii_case("wal")),
1905                        backend.read_only,
1906                    )?;
1907                }
1908
1909                backend.resolve_disk_guard(&khive_db::DiskGuardEnvironment::default())?;
1910
1911                // Reject fields that are parsed but not yet implemented: silently
1912                // accepting them would let misconfiguration slip past startup.
1913                if backend.cache_mb.is_some() {
1914                    return Err(ConfigError::UnsupportedBackendField {
1915                        name: backend.name.clone(),
1916                        field: "cache_mb",
1917                    });
1918                }
1919                if backend.journal_mode.is_some() {
1920                    return Err(ConfigError::UnsupportedBackendField {
1921                        name: backend.name.clone(),
1922                        field: "journal_mode",
1923                    });
1924                }
1925            }
1926        }
1927
1928        let defined: Vec<&str> = if self.backends.is_empty() {
1929            vec![BackendId::MAIN]
1930        } else {
1931            self.backends.iter().map(|b| b.name.as_str()).collect()
1932        };
1933        for (pack_name, pack_cfg) in &self.packs {
1934            if !defined.contains(&pack_cfg.backend.as_str()) {
1935                return Err(ConfigError::UnknownPackBackend {
1936                    pack: pack_name.clone(),
1937                    backend: pack_cfg.backend.clone(),
1938                    defined: defined.join(", "),
1939                });
1940            }
1941        }
1942
1943        if !self.backends.is_empty() {
1944            let missing: Vec<_> = [SubstrateKind::Note, SubstrateKind::Entity]
1945                .into_iter()
1946                .filter(|kind| {
1947                    !self.backends.iter().any(|backend| {
1948                        backend
1949                            .served_kinds
1950                            .as_ref()
1951                            .is_none_or(|served| served.contains(kind))
1952                    })
1953                })
1954                .collect();
1955            if !missing.is_empty() {
1956                return Err(ConfigError::MissingBackendSearchKinds {
1957                    kinds: missing,
1958                    defined: defined.join(", "),
1959                });
1960            }
1961        }
1962
1963        // Validate [display] timezone (ADR-169): an unrecognized IANA zone
1964        // name is a startup error, not a silent fallback to the host zone.
1965        if let Some(tz) = self.display.timezone.as_deref() {
1966            if tz.trim().is_empty() || tz.parse::<chrono_tz::Tz>().is_err() {
1967                return Err(ConfigError::InvalidDisplayTimezone {
1968                    timezone: tz.to_string(),
1969                });
1970            }
1971        }
1972
1973        // Validate [[git_write.allowed]] entries (ADR-108 Amendment): each
1974        // repo must be a non-empty absolute path, and each entry must carry
1975        // at least one branch pattern — an entry with an empty `branches`
1976        // list would silently allowlist a repo for no branch at all, which
1977        // reads as "configured" while behaving identically to "not
1978        // allowlisted"; reject it loudly instead of leaving that trap.
1979        for entry in &self.git_write.allowed {
1980            if entry.repo.trim().is_empty() {
1981                return Err(ConfigError::InvalidGitWriteEntry {
1982                    repo: entry.repo.clone(),
1983                    reason: "repo must not be empty".to_string(),
1984                });
1985            }
1986            if !Path::new(&entry.repo).is_absolute() {
1987                return Err(ConfigError::InvalidGitWriteEntry {
1988                    repo: entry.repo.clone(),
1989                    reason: "repo must be an absolute path".to_string(),
1990                });
1991            }
1992            if entry.branches.is_empty() {
1993                return Err(ConfigError::InvalidGitWriteEntry {
1994                    repo: entry.repo.clone(),
1995                    reason: "branches must not be empty".to_string(),
1996                });
1997            }
1998            if entry.branches.iter().any(|b| b.trim().is_empty()) {
1999                return Err(ConfigError::InvalidGitWriteEntry {
2000                    repo: entry.repo.clone(),
2001                    reason: "branches entries must not be empty".to_string(),
2002                });
2003            }
2004            // ADR-108 specifies exact name or a SINGLE-star wildcard per
2005            // branch pattern -- a pattern with two or more `*` (e.g. `**`,
2006            // `rel-*-*-final`) is a wider grammar than the ADR authorizes
2007            // and must be rejected at config load, not silently accepted.
2008            if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
2009                return Err(ConfigError::InvalidGitWriteEntry {
2010                    repo: entry.repo.clone(),
2011                    reason: format!(
2012                        "branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
2013                    ),
2014                });
2015            }
2016        }
2017
2018        validate_peer_engines(&self.engines)
2019    }
2020
2021    /// First peer used by single-engine compatibility APIs, if any.
2022    pub fn default_engine(&self) -> Option<&EngineConfig> {
2023        self.engines.first()
2024    }
2025}
2026
2027// ---- Env-var fallback ----
2028
2029/// Build an in-memory `KhiveConfig` from the legacy env-var path.
2030///
2031/// Used when no config file is present. Emits `tracing::info!` directing
2032/// operators to migrate to `~/.khive/config.toml`.
2033///
2034/// The primary model (`KHIVE_EMBEDDING_MODEL`) becomes the first peer;
2035/// additional models follow in their existing order. When only
2036/// `KHIVE_ADDITIONAL_EMBEDDING_MODELS` is set, the built-in default model is
2037/// synthesized as the primary — the additional list is additive, never a
2038/// replacement for the primary (khive#1221; matches `RuntimeConfig::default()`,
2039/// which resolves an unset `KHIVE_EMBEDDING_MODEL` to the built-in default).
2040pub fn config_from_env() -> KhiveConfig {
2041    let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
2042        .ok()
2043        .filter(|s| !s.trim().is_empty());
2044    let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
2045        .ok()
2046        .unwrap_or_default();
2047    let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
2048        .into_iter()
2049        .filter(|s| !s.is_empty())
2050        .collect();
2051
2052    if primary_model.is_none() && additional.is_empty() {
2053        return KhiveConfig::default();
2054    }
2055
2056    tracing::info!(
2057        "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
2058    );
2059
2060    config_from_env_parts(primary_model, additional)
2061}
2062
2063/// Pure core of [`config_from_env`], separated so the engine-list derivation
2064/// is testable without mutating process-global environment variables.
2065fn config_from_env_parts(primary_model: Option<String>, additional: Vec<String>) -> KhiveConfig {
2066    let mut engines = Vec::new();
2067
2068    let primary =
2069        primary_model.unwrap_or_else(|| lattice_embed::EmbeddingModel::AllMiniLmL6V2.to_string());
2070    for model in std::iter::once(primary).chain(additional) {
2071        let name = canonical_engine_name(&model);
2072        if engines
2073            .iter()
2074            .any(|engine: &EngineConfig| engine.name == name)
2075        {
2076            continue;
2077        }
2078        engines.push(EngineConfig {
2079            name,
2080            weight: 1.0,
2081            dims: None,
2082        });
2083    }
2084
2085    KhiveConfig {
2086        engines,
2087        ..KhiveConfig::default()
2088    }
2089}
2090
2091// ---- Tests ----
2092
2093// Kept in-crate (not tests/): exercises private ConfigError variants not part
2094// of the public API.
2095#[cfg(test)]
2096#[path = "engine_config_tests.rs"]
2097mod tests;