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