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::path::{Path, PathBuf};
8
9use khive_types::namespace::Namespace;
10use serde::Deserialize;
11use thiserror::Error;
12
13use crate::presentation::OutputFormat;
14
15// ---- Error type ----
16
17/// Errors produced while loading or validating a `KhiveConfig`.
18#[derive(Debug, Error)]
19pub enum ConfigError {
20    #[error("config file I/O: {0}")]
21    Io(#[from] std::io::Error),
22
23    #[error("config TOML parse error in {path}: {source}")]
24    Parse {
25        path: PathBuf,
26        #[source]
27        source: toml::de::Error,
28    },
29
30    #[error("exactly one engine must be marked `default = true`; found {found}")]
31    DefaultCount { found: usize },
32
33    #[error("duplicate engine name: {name:?}")]
34    DuplicateName { name: String },
35
36    #[error(
37        "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
38    )]
39    UnknownModel { name: String, model: String },
40
41    #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
42    InvalidFusionWeight { name: String, value: f64 },
43
44    #[error("actor.id {id:?} is not a valid namespace: {reason}")]
45    InvalidActorId { id: String, reason: String },
46
47    #[error("duplicate backend name: {name:?}")]
48    DuplicateBackendName { name: String },
49
50    #[error(
51        "[packs.{pack}].backend = {backend:?} references an unknown backend; \
52         defined backends: {defined}"
53    )]
54    UnknownPackBackend {
55        pack: String,
56        backend: String,
57        defined: String,
58    },
59
60    #[error(
61        "[[backends]] entry {name:?}: field `{field}` is not yet supported; \
62         remove it from the config or wait for a future release that implements it"
63    )]
64    UnsupportedBackendField { name: String, field: &'static str },
65
66    #[error(
67        "top-level `db = {value:?}` is not a supported config-file key; \
68         use `--db` / `KHIVE_DB` to select a single-file database, or \
69         `[[backends]].path` to declare storage backend topology"
70    )]
71    UnsupportedTopLevelDb { value: String },
72
73    #[error("[[git_write.allowed]] entry {repo:?}: {reason}")]
74    InvalidGitWriteEntry { repo: String, reason: String },
75}
76
77// ---- Config structs ----
78
79/// Configuration for a single embedding engine.
80#[derive(Debug, Clone, Deserialize)]
81pub struct EngineConfig {
82    /// Logical name used to reference this engine in logs and fusion.
83    pub name: String,
84
85    /// Lattice-embed model name (e.g. `"all-minilm-l6-v2"`).
86    ///
87    /// Must be parseable via `lattice_embed::EmbeddingModel::from_str` (or a
88    /// recognised short alias handled by `parse_embedding_model_alias`).
89    pub model: String,
90
91    /// When `true`, this engine's model becomes the primary (`RuntimeConfig::embedding_model`).
92    /// Exactly one engine in the list must set this. If absent, defaults to `false`.
93    #[serde(default)]
94    pub default: bool,
95
96    /// RRF fusion weight for weighted multi-engine fusion.
97    ///
98    /// Only meaningful when multiple engines are loaded. Must be `> 0` when
99    /// present. `None` means the engine participates in fusion with equal weight
100    /// to other engines that also lack a `fusion_weight`.
101    ///
102    /// For RRF: `fusion_weight` provides per-engine relative importance during
103    /// weighted RRF; it does NOT apply to rank-based unweighted RRF (the weights
104    /// are injected into `FusionStrategy::Weighted` only).
105    pub fusion_weight: Option<f64>,
106
107    /// Expected output dimensionality (optional sanity check).
108    ///
109    /// Not used at runtime — dimensions are authoritative from
110    /// `EmbeddingModel::dimensions()`. Present so operators can document the
111    /// expected shape alongside the model name.
112    pub dims: Option<u32>,
113}
114
115/// Actor configuration — the default namespace / identity for this khive instance.
116///
117/// Corresponds to the `[actor]` TOML section. `id` is used as the
118/// `default_namespace` for gate/attribution policy input. OSS dispatch pins
119/// writes to the shared `local` namespace regardless of this value (ADR-007
120/// Rev 4 Rule 0); cloud deployments derive the namespace from an authenticated
121/// `NamespaceToken` instead.
122///
123/// ```toml
124/// [actor]
125/// id = "lambda:leo"                          # attribution identity (required)
126/// display_name = "example actor"   # human label (optional)
127/// visible_namespaces = ["lambda:khive", "local"]  # widens default read scope (ADR-007 Rev 4 Rule 3b)
128/// ```
129///
130/// `visible_namespaces` is consumed by OSS dispatch to widen the DEFAULT
131/// multi-record read scope to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4
132/// Rule 3b). Writes remain pinned to `'local'`. An explicit `namespace=` request
133/// param is a precise single-namespace escape and is not widened. A cloud gate
134/// may also consult this list as policy input at its own layer.
135#[derive(Debug, Clone, Deserialize, Default)]
136pub struct ActorConfig {
137    /// Namespace identifier used as the default actor for all operations.
138    ///
139    /// Must be a valid `Namespace` string (e.g. `"local"`, `"lambda:khive"`).
140    /// Defaults to `"local"` when absent — backward-compatible with pre-actor
141    /// deployments.
142    #[serde(default)]
143    pub id: Option<String>,
144
145    /// Optional human-readable label for this actor. Not used by the runtime;
146    /// surfaced in introspection and log output only.
147    #[serde(default)]
148    pub display_name: Option<String>,
149
150    /// Additional namespaces that widen the DEFAULT multi-record read scope
151    /// to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4 Rule 3b). Each string
152    /// must be a valid `Namespace`. Writes remain pinned to `'local'`. An
153    /// explicit `namespace=` request param is a precise escape and is not widened
154    /// by this list. A cloud gate may also consult it as policy input.
155    #[serde(default)]
156    pub visible_namespaces: Option<Vec<String>>,
157
158    /// Namespaces this actor's comm.send/reply may deliver messages INTO
159    /// (outbound, sender-side). Empty by default — cross-namespace delivery
160    /// denied unless explicitly declared. The comm handler uses an ordinary
161    /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
162    /// the token itself is NOT type-enforced write-only. The recipient-side
163    /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
164    /// a future cloud-path authorization ADR (not yet written).
165    ///
166    /// Each entry must be a valid `Namespace` string; validated at
167    /// config-load time. An empty list preserves the prior deny-all behavior
168    /// for any actor that does not add this field.
169    #[serde(default)]
170    pub allowed_outbound_namespaces: Vec<String>,
171}
172
173// ---- Per-pack backend config (ADR-028) ----
174
175/// Storage backend kind.
176#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
177#[serde(rename_all = "lowercase")]
178pub enum BackendKind {
179    /// SQLite file-backed database (default).
180    #[default]
181    Sqlite,
182    /// In-memory database — for testing only; state is lost on restart.
183    Memory,
184}
185
186/// Configuration for a named storage backend.
187///
188/// Corresponds to a `[[backends]]` entry in `khive.toml`.
189/// When no `[[backends]]` section is present, a single implicit `main` backend
190/// is synthesised from the existing `--db` / `KHIVE_DB` / default-path resolution.
191/// All packs fall back to `main` when their name is absent from `[packs]`.
192///
193/// ```toml
194/// [[backends]]
195/// name = "knowledge"
196/// kind = "sqlite"
197/// path = "~/.khive/knowledge.db"
198/// cache_mb = 128
199/// journal_mode = "wal"
200/// read_only = false
201/// ```
202#[derive(Debug, Clone, Deserialize)]
203pub struct BackendConfig {
204    /// Unique backend name. Referenced by `[packs.<name>].backend`.
205    pub name: String,
206    /// Storage backend kind. Defaults to `sqlite`.
207    #[serde(default)]
208    pub kind: BackendKind,
209    /// Filesystem path for `sqlite` kind. Tilde is expanded to `$HOME`.
210    /// `None` for `memory` kind (path is ignored when present).
211    pub path: Option<std::path::PathBuf>,
212    /// SQLite page-cache size in MiB.
213    pub cache_mb: Option<u32>,
214    /// SQLite journal mode (e.g. `"wal"`).
215    pub journal_mode: Option<String>,
216    /// Open the backend read-only. Defaults to `false`.
217    #[serde(default)]
218    pub read_only: bool,
219}
220
221/// Per-pack backend assignment.
222///
223/// Corresponds to a `[packs.<pack-name>]` entry in `khive.toml`.
224/// Packs whose name is absent from `[packs]` fall back to the `main` backend.
225///
226/// ```toml
227/// [packs.knowledge]
228/// backend = "knowledge"
229/// ```
230#[derive(Debug, Clone, Deserialize)]
231pub struct PackConfig {
232    /// Backend name this pack is assigned to. Must match a `[[backends]].name`.
233    pub backend: String,
234}
235
236// ---- Blob store config (ADR-111 Amendment 2) ----
237
238/// `[storage.blob]` section: a closed `backend = "fs" | "s3"` selector.
239///
240/// Internally tagged on `backend` with `deny_unknown_fields`: an unknown
241/// top-level key, a field that belongs to the other backend variant (e.g.
242/// `bucket` under `backend = "fs"`), or an S3 credential field (never
243/// accepted in TOML -- ADR-111 Amendment 2 reads credentials from the
244/// process environment only) are all rejected at config-load time by the
245/// same mechanism, since each variant only declares its own fields.
246///
247/// ```toml
248/// [storage.blob]
249/// backend = "fs"
250/// root = "/var/lib/khive/blobs"
251/// floor_bytes = 100000000000
252/// ```
253///
254/// ```toml
255/// [storage.blob]
256/// backend = "s3"
257/// bucket = "khive-blobs"
258/// region = "us-east-1"
259/// endpoint = "https://objects.example.invalid"
260/// prefix = "blobs"
261/// ```
262#[derive(Debug, Clone, Deserialize)]
263#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
264pub enum BlobConfig {
265    /// Filesystem-backed blob storage (`FsBlobStore`). Root resolution is
266    /// unchanged from khive#292: `KHIVE_BLOB_ROOT` env var, then this
267    /// `root`, then `<db_dir>/blobs`.
268    Fs {
269        #[serde(default)]
270        root: Option<String>,
271        #[serde(default)]
272        floor_bytes: Option<u64>,
273    },
274    /// S3-compatible blob storage (`S3BlobStore`). `KHIVE_BLOB_ROOT` has no
275    /// effect for this backend. Credentials always come from
276    /// `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN` in the
277    /// process environment, never from this section.
278    S3 {
279        bucket: String,
280        region: String,
281        #[serde(default)]
282        endpoint: Option<String>,
283        #[serde(default)]
284        prefix: Option<String>,
285        #[serde(default)]
286        allow_http: Option<bool>,
287    },
288}
289
290/// `[storage]` section in `khive.toml`. Holds storage-layer config not
291/// already covered by `[[backends]]` (ADR-028).
292#[derive(Debug, Clone, Deserialize, Default)]
293pub struct StorageSectionConfig {
294    /// Blob store backend selector (ADR-111 Amendment 2). Absent means
295    /// `FsBlobStore` at the existing root-resolution precedence, unchanged
296    /// from khive#292 -- existing configurations keep behaving exactly as
297    /// they did before this section existed.
298    #[serde(default)]
299    pub blob: Option<BlobConfig>,
300}
301
302// ---- git-write policy (ADR-108 Amendment) ----
303
304/// One `[[git_write.allowed]]` entry: a repo this operator has declared
305/// trusted for khive-mediated git writes, plus the branches on it a write
306/// verb (`git.commit`/`git.branch`/`git.push`) may target.
307///
308/// ```toml
309/// [[git_write.allowed]]
310/// repo = "/abs/path/repo"
311/// branches = ["feat/*", "fix/*"]
312/// ```
313#[derive(Debug, Clone, Deserialize)]
314pub struct GitWriteEntryConfig {
315    /// Absolute local path to the allowlisted repository.
316    pub repo: String,
317    /// Non-empty list of exact branch names or single-`*`-wildcard globs
318    /// this repo entry permits writes against.
319    pub branches: Vec<String>,
320}
321
322/// `[git_write]` section — the closed repo/branch allowlist consulted by
323/// `khive-pack-git`'s write verbs at the handler level (ADR-108 Amendment),
324/// independent of Gate policy. Absent or empty `allowed` is the fail-closed
325/// default: the write verbs report themselves unavailable rather than
326/// defaulting open.
327///
328/// ```toml
329/// [[git_write.allowed]]
330/// repo = "/abs/path/repo"
331/// branches = ["feat/*", "fix/*"]
332/// ```
333#[derive(Debug, Clone, Deserialize, Default)]
334pub struct GitWriteSectionConfig {
335    #[serde(default)]
336    pub allowed: Vec<GitWriteEntryConfig>,
337}
338
339/// Top-level khive configuration loaded from `khive.toml` or `config.toml`.
340///
341/// Sections consumed today:
342/// - `[[engines]]`: embedding engine declarations
343/// - `[actor]`: default namespace / identity (OSS actor model)
344/// - `[runtime]`: runtime knobs (namespace, brain_profile)
345/// - `[[backends]]`: storage backend declarations (ADR-028)
346/// - `[packs.<name>]`: per-pack backend assignments (ADR-028)
347///
348/// Unknown keys are silently ignored by serde — forward-compatible.
349#[derive(Debug, Clone, Deserialize, Default)]
350pub struct KhiveConfig {
351    /// Typed only so a top-level `db` key can be rejected loudly by
352    /// [`KhiveConfig::validate`] instead of being silently ignored as an
353    /// unknown key. Not a supported config-file storage selector: single-file
354    /// database selection is `--db`/`KHIVE_DB`, and storage topology is
355    /// `[[backends]].path`.
356    #[serde(default)]
357    pub db: Option<String>,
358
359    /// Embedding engine declarations.
360    #[serde(default)]
361    pub engines: Vec<EngineConfig>,
362
363    /// Default actor identity for this khive instance.
364    ///
365    /// When present, `actor.id` feeds configuration identity and gate/attribution
366    /// policy input.  A non-`'local'` `actor.id` is folded into the default READ
367    /// visible-set at config load (ADR-007 Rev 4 Rule 3b) — it widens what default
368    /// multi-record reads return, but never routes writes or sets `default_namespace`.
369    /// Cloud model derives actor identity from an authenticated token.
370    #[serde(default)]
371    pub actor: ActorConfig,
372
373    /// Runtime knobs: namespace overrides, brain profile, etc.
374    #[serde(default)]
375    pub runtime: RuntimeSectionConfig,
376
377    /// Named storage backends (ADR-028).
378    ///
379    /// When absent or empty, a single implicit `main` backend is used and all
380    /// packs share it — identical to pre-ADR-028 behavior.
381    #[serde(default)]
382    pub backends: Vec<BackendConfig>,
383
384    /// Per-pack backend assignments (ADR-028).
385    ///
386    /// Maps pack name to backend name. Packs absent from this map fall back to
387    /// the `main` backend. Validated at load time: every referenced backend name
388    /// must appear in `backends`.
389    #[serde(default)]
390    pub packs: std::collections::HashMap<String, PackConfig>,
391
392    /// Git-write policy allowlist (ADR-108 Amendment). Absent or empty
393    /// `allowed` fails closed — `khive-pack-git`'s write verbs are
394    /// unavailable until this section is populated.
395    #[serde(default)]
396    pub git_write: GitWriteSectionConfig,
397
398    /// Storage-layer config not covered by `[[backends]]` (ADR-111
399    /// Amendment 2: `[storage.blob]`'s `fs`/`s3` selector).
400    #[serde(default)]
401    pub storage: StorageSectionConfig,
402}
403
404/// `[runtime]` section in `khive.toml`.
405///
406/// Carries runtime knobs that mirror the CLI flag / env var tier.
407/// All fields are optional; absent keys fall through to env vars or built-in
408/// defaults.
409#[derive(Debug, Clone, Deserialize, Default)]
410pub struct RuntimeSectionConfig {
411    /// Brain profile ID to use for `memory.feedback` / `knowledge.feedback`
412    /// and recall-time score boosting (ADR-035 §Brain profile configuration).
413    ///
414    /// Mirrors `--brain-profile` / `KHIVE_BRAIN_PROFILE`. When absent, the
415    /// namespace-bound profile (via `brain.resolve`) is tried, then the
416    /// global tuning prior is used as the final fallback.
417    #[serde(default)]
418    pub brain_profile: Option<String>,
419
420    /// Default output serialization format (ADR-078).
421    ///
422    /// Mirrors `--output-format` / `KHIVE_OUTPUT_FORMAT`. Precedence (highest to lowest):
423    /// per-request `format` field → `KHIVE_OUTPUT_FORMAT` → this field → builtin `json`.
424    ///
425    /// Accepted values: `"json"` (default), `"auto"`, `"table"`.
426    #[serde(default)]
427    pub default_output_format: Option<OutputFormat>,
428}
429
430impl KhiveConfig {
431    /// Load and validate a `KhiveConfig` from an explicit path.
432    ///
433    /// Search order:
434    /// 1. `path` argument (explicit override — e.g. from `--config` / `KHIVE_CONFIG`)
435    /// 2. `./.khive/config.toml` (project-local config, relative to the MCP server cwd)
436    ///
437    /// The project-local default collocates config with the `khive-test.db` that already
438    /// lives under `.khive/` in each project directory. `~/.khive/config.toml` is searched
439    /// by [`KhiveConfig::load_with_home_fallback`] when the project-local file is absent.
440    ///
441    /// If the resolved file does **not exist**, returns `Ok(None)`.
442    /// A missing config is not an error — callers fall back to the env-var path.
443    ///
444    /// If the file exists but cannot be parsed, returns a `ConfigError`.
445    /// After parsing, `validate()` runs and any logical errors are returned.
446    pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
447        let resolved = match path {
448            Some(p) => p.to_path_buf(),
449            None => PathBuf::from(".khive/config.toml"),
450        };
451
452        if !resolved.exists() {
453            return Ok(None);
454        }
455
456        let raw = std::fs::read_to_string(&resolved)?;
457        let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
458            path: resolved,
459            source,
460        })?;
461        cfg.validate()?;
462        Ok(Some(cfg))
463    }
464
465    /// Load config with the full resolution order:
466    ///
467    /// 1. Explicit `path` (from `--config` / `KHIVE_CONFIG`)
468    /// 2. `./khive.toml` (project-local, project root)
469    /// 3. `<db-dir>/config.toml` (project-local, anchored to the resolved database's
470    ///    own directory — see `project_config_anchor_dir`)
471    /// 4. `~/.khive/config.toml` (user-global)
472    ///
473    /// Returns the first file found, or `Ok(None)` when none exist.
474    /// Parse errors are propagated immediately — a malformed config is always
475    /// an error regardless of which tier it came from.
476    ///
477    /// `db_path` should be the same database path the caller is about to open
478    /// (or has already resolved). Passing it makes tier 3 resolve identically
479    /// for any two processes that target the same database, regardless of
480    /// their process working directory — this is what lets a thin client and
481    /// a warm daemon serving the same database agree on one config file. Pass
482    /// `None` when no database path is known yet; tier 3 then falls back to
483    /// the process cwd, matching the pre-existing behavior.
484    pub fn load_with_home_fallback(
485        path: Option<&Path>,
486        db_path: Option<&Path>,
487    ) -> Result<Option<Self>, ConfigError> {
488        // Tier 1: explicit path (highest priority).
489        if let Some(p) = path {
490            return Self::load(Some(p));
491        }
492
493        // Tiers 2-4: search project root, db-anchored hidden dir, user-global.
494        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
495        let home_root = std::env::var_os("HOME").map(PathBuf::from);
496        Self::load_with_roots(&project_root, home_root.as_deref(), db_path)
497    }
498
499    /// Testable inner search: tiers 2-4, given explicit roots instead of
500    /// reading `cwd` and `HOME` from process state.
501    ///
502    /// - Tier 2: `<project_root>/khive.toml` (still cwd-anchored — unchanged)
503    /// - Tier 3: `<db_dir>/config.toml`, anchored to `db_path` rather than
504    ///   `project_root` (see `project_config_anchor_dir`); falls back to
505    ///   `<project_root>/.khive/config.toml` when `db_path` is `None`
506    /// - Tier 4: `<home_root>/.khive/config.toml` (skipped when `None`)
507    pub(crate) fn load_with_roots(
508        project_root: &Path,
509        home_root: Option<&Path>,
510        db_path: Option<&Path>,
511    ) -> Result<Option<Self>, ConfigError> {
512        // Tier 2: project root khive.toml.
513        let tier2 = project_root.join("khive.toml");
514        if tier2.exists() {
515            return Self::load(Some(&tier2));
516        }
517
518        // Tier 3: project-local hidden dir, anchored to the resolved database's
519        // own directory instead of the process cwd.
520        let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
521        if tier3.exists() {
522            return Self::load(Some(&tier3));
523        }
524
525        // Tier 4: user-global ~/.khive/config.toml.
526        if let Some(home) = home_root {
527            let tier4 = home.join(".khive/config.toml");
528            if tier4.exists() {
529                return Self::load(Some(&tier4));
530            }
531        }
532
533        Ok(None)
534    }
535
536    /// Resolve the directory searched for the tier-3 project-local config file.
537    ///
538    /// Anchored to the directory containing the resolved database file, not the
539    /// process cwd: two processes at different working directories that open the
540    /// same database agree on this directory, which is what keeps their
541    /// `config_id` fingerprints in sync (a client and a warm daemon serving the
542    /// same database must resolve identical config so the daemon accepts the
543    /// client's forwarded requests instead of rejecting them on a config
544    /// mismatch).
545    ///
546    /// `db_path` is canonicalized first so symlinks/relative components collapse
547    /// to the same absolute directory regardless of caller cwd. The database file
548    /// may not exist yet (first run before anything has been written) — in that
549    /// case canonicalization fails and the path is absolutized against
550    /// `project_root` instead (or used as-is if already absolute); this must
551    /// never panic, it is the expected cold-start case.
552    ///
553    /// If `db_dir` (the resolved database's parent directory) is itself named
554    /// `.khive`, the config lives directly inside it (`<db_dir>/config.toml`) —
555    /// this is the common case where the database is `<root>/.khive/khive.db`.
556    /// Otherwise the config lives in a `.khive` subdirectory of `db_dir`.
557    ///
558    /// `db_path == None` (e.g. an in-memory database, or no database path known
559    /// yet) falls back to `<project_root>/.khive`, preserving the pre-existing
560    /// cwd-anchored behavior for callers with no database to anchor on.
561    fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
562        let Some(db_path) = db_path else {
563            return project_root.join(".khive");
564        };
565
566        let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
567            if db_path.is_absolute() {
568                db_path.to_path_buf()
569            } else {
570                project_root.join(db_path)
571            }
572        });
573
574        let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
575
576        if db_dir.file_name().is_some_and(|name| name == ".khive") {
577            db_dir
578        } else {
579            db_dir.join(".khive")
580        }
581    }
582
583    /// Validate the parsed config for logical consistency.
584    ///
585    /// Checks:
586    /// - Exactly one engine has `default = true` (when the list is non-empty).
587    /// - Engine names are unique.
588    /// - `fusion_weight`, when present, is `> 0`.
589    ///
590    /// Model name validity is checked lazily at runtime (the config loader does
591    /// not import `lattice_embed` directly to keep the dep surface minimal).
592    pub fn validate(&self) -> Result<(), ConfigError> {
593        // Reject a top-level `db` key loudly instead of letting serde's
594        // forward-compatible unknown-key tolerance silently swallow it: a
595        // config author expecting `db=` to select the database would
596        // otherwise get silent divergence from `--db`/`KHIVE_DB`.
597        if let Some(value) = self.db.as_deref() {
598            if !value.is_empty() {
599                return Err(ConfigError::UnsupportedTopLevelDb {
600                    value: value.to_string(),
601                });
602            }
603        }
604
605        // Validate actor.id when present — an invalid namespace is a startup error,
606        // not a silent fallback.
607        if let Some(id) = self.actor.id.as_deref() {
608            if id.is_empty() {
609                return Err(ConfigError::InvalidActorId {
610                    id: id.to_string(),
611                    reason: "actor.id must not be empty; remove the key or provide a value"
612                        .to_string(),
613                });
614            }
615            Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
616                id: id.to_string(),
617                reason: e.to_string(),
618            })?;
619        }
620
621        if let Some(ref vis) = self.actor.visible_namespaces {
622            for ns_str in vis {
623                if ns_str.is_empty() {
624                    return Err(ConfigError::InvalidActorId {
625                        id: ns_str.clone(),
626                        reason: "visible_namespaces entries must not be empty".to_string(),
627                    });
628                }
629                Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
630                    id: ns_str.clone(),
631                    reason: format!("invalid visible namespace: {e}"),
632                })?;
633            }
634        }
635
636        // Validate actor.allowed_outbound_namespaces (fail-closed at startup on malformed entry).
637        for ns_str in &self.actor.allowed_outbound_namespaces {
638            if ns_str.is_empty() {
639                return Err(ConfigError::InvalidActorId {
640                    id: ns_str.clone(),
641                    reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
642                });
643            }
644            Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
645                id: ns_str.clone(),
646                reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
647            })?;
648        }
649
650        // Backend names must be unique.
651        if !self.backends.is_empty() {
652            let mut seen_backends = std::collections::HashSet::new();
653            for backend in &self.backends {
654                if !seen_backends.insert(backend.name.clone()) {
655                    return Err(ConfigError::DuplicateBackendName {
656                        name: backend.name.clone(),
657                    });
658                }
659
660                // Reject fields that are parsed but not yet implemented: silently
661                // accepting them would let misconfiguration slip past startup.
662                if backend.cache_mb.is_some() {
663                    return Err(ConfigError::UnsupportedBackendField {
664                        name: backend.name.clone(),
665                        field: "cache_mb",
666                    });
667                }
668                if backend.journal_mode.is_some() {
669                    return Err(ConfigError::UnsupportedBackendField {
670                        name: backend.name.clone(),
671                        field: "journal_mode",
672                    });
673                }
674            }
675
676            // Every pack-referenced backend name must be declared in `backends`.
677            let defined: Vec<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
678            for (pack_name, pack_cfg) in &self.packs {
679                if !defined.contains(&pack_cfg.backend.as_str()) {
680                    return Err(ConfigError::UnknownPackBackend {
681                        pack: pack_name.clone(),
682                        backend: pack_cfg.backend.clone(),
683                        defined: defined.join(", "),
684                    });
685                }
686            }
687        }
688
689        // Validate [[git_write.allowed]] entries (ADR-108 Amendment): each
690        // repo must be a non-empty absolute path, and each entry must carry
691        // at least one branch pattern — an entry with an empty `branches`
692        // list would silently allowlist a repo for no branch at all, which
693        // reads as "configured" while behaving identically to "not
694        // allowlisted"; reject it loudly instead of leaving that trap.
695        for entry in &self.git_write.allowed {
696            if entry.repo.trim().is_empty() {
697                return Err(ConfigError::InvalidGitWriteEntry {
698                    repo: entry.repo.clone(),
699                    reason: "repo must not be empty".to_string(),
700                });
701            }
702            if !Path::new(&entry.repo).is_absolute() {
703                return Err(ConfigError::InvalidGitWriteEntry {
704                    repo: entry.repo.clone(),
705                    reason: "repo must be an absolute path".to_string(),
706                });
707            }
708            if entry.branches.is_empty() {
709                return Err(ConfigError::InvalidGitWriteEntry {
710                    repo: entry.repo.clone(),
711                    reason: "branches must not be empty".to_string(),
712                });
713            }
714            if entry.branches.iter().any(|b| b.trim().is_empty()) {
715                return Err(ConfigError::InvalidGitWriteEntry {
716                    repo: entry.repo.clone(),
717                    reason: "branches entries must not be empty".to_string(),
718                });
719            }
720            // ADR-108 specifies exact name or a SINGLE-star wildcard per
721            // branch pattern -- a pattern with two or more `*` (e.g. `**`,
722            // `rel-*-*-final`) is a wider grammar than the ADR authorizes
723            // and must be rejected at config load, not silently accepted.
724            if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
725                return Err(ConfigError::InvalidGitWriteEntry {
726                    repo: entry.repo.clone(),
727                    reason: format!(
728                        "branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
729                    ),
730                });
731            }
732        }
733
734        if self.engines.is_empty() {
735            return Ok(());
736        }
737
738        let mut seen_names = std::collections::HashSet::new();
739        for engine in &self.engines {
740            if !seen_names.insert(engine.name.clone()) {
741                return Err(ConfigError::DuplicateName {
742                    name: engine.name.clone(),
743                });
744            }
745        }
746
747        let default_count = self.engines.iter().filter(|e| e.default).count();
748        if default_count != 1 {
749            return Err(ConfigError::DefaultCount {
750                found: default_count,
751            });
752        }
753
754        // Reject non-finite fusion_weight explicitly: NaN doesn't satisfy `w <= 0.0`
755        // and +inf is unbounded, so neither is caught by the range check alone.
756        for engine in &self.engines {
757            if let Some(w) = engine.fusion_weight {
758                if !w.is_finite() || w <= 0.0 {
759                    return Err(ConfigError::InvalidFusionWeight {
760                        name: engine.name.clone(),
761                        value: w,
762                    });
763                }
764            }
765        }
766
767        Ok(())
768    }
769
770    /// Return the engine flagged `default = true`, or `None` if the list is empty.
771    pub fn default_engine(&self) -> Option<&EngineConfig> {
772        self.engines.iter().find(|e| e.default)
773    }
774}
775
776// ---- Env-var fallback ----
777
778/// Build an in-memory `KhiveConfig` from the legacy env-var path.
779///
780/// Used when no config file is present. Emits `tracing::info!` directing
781/// operators to migrate to `~/.khive/config.toml`.
782///
783/// The primary model (`KHIVE_EMBEDDING_MODEL`) becomes the `default = true`
784/// engine; additional models become non-default secondary engines.
785pub fn config_from_env() -> KhiveConfig {
786    let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
787        .ok()
788        .filter(|s| !s.trim().is_empty());
789    let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
790        .ok()
791        .unwrap_or_default();
792    let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
793        .into_iter()
794        .filter(|s| !s.is_empty())
795        .collect();
796
797    if primary_model.is_none() && additional.is_empty() {
798        return KhiveConfig::default();
799    }
800
801    tracing::info!(
802        "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
803    );
804
805    let mut engines = Vec::new();
806
807    if let Some(model) = primary_model {
808        engines.push(EngineConfig {
809            name: "default".to_string(),
810            model,
811            default: true,
812            fusion_weight: None,
813            dims: None,
814        });
815    }
816
817    for (i, model) in additional.into_iter().enumerate() {
818        engines.push(EngineConfig {
819            name: format!("engine-{}", i + 1),
820            model,
821            default: false,
822            fusion_weight: None,
823            dims: None,
824        });
825    }
826
827    // If no primary was specified but there are additional models, promote the
828    // first additional model as the default so the list stays valid.
829    if !engines.is_empty() && !engines.iter().any(|e| e.default) {
830        engines[0].default = true;
831    }
832
833    KhiveConfig {
834        engines,
835        ..KhiveConfig::default()
836    }
837}
838
839// ---- Tests ----
840
841// Kept inline (not tests/): exercises private ConfigError variants not part
842// of the public API.
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
848        let path = dir.path().join("config.toml");
849        std::fs::write(&path, content).unwrap();
850        path
851    }
852
853    #[test]
854    fn test_load_minimal_config() {
855        let dir = tempfile::tempdir().unwrap();
856        let path = write_toml(
857            &dir,
858            r#"
859[[engines]]
860name = "x"
861model = "all-minilm-l6-v2"
862default = true
863"#,
864        );
865        let cfg = KhiveConfig::load(Some(&path))
866            .expect("load should succeed")
867            .expect("file should be found");
868        assert_eq!(cfg.engines.len(), 1);
869        assert_eq!(cfg.engines[0].name, "x");
870        assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
871        assert!(cfg.engines[0].default);
872    }
873
874    #[test]
875    fn test_default_engine_required_when_engines_present() {
876        let dir = tempfile::tempdir().unwrap();
877        let path = write_toml(
878            &dir,
879            r#"
880[[engines]]
881name = "a"
882model = "all-minilm-l6-v2"
883"#,
884        );
885        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
886        assert!(
887            matches!(err, ConfigError::DefaultCount { found: 0 }),
888            "expected DefaultCount {{ found: 0 }}, got {err:?}"
889        );
890    }
891
892    #[test]
893    fn test_multiple_default_rejected() {
894        let dir = tempfile::tempdir().unwrap();
895        let path = write_toml(
896            &dir,
897            r#"
898[[engines]]
899name = "a"
900model = "all-minilm-l6-v2"
901default = true
902
903[[engines]]
904name = "b"
905model = "paraphrase-multilingual-minilm-l12-v2"
906default = true
907"#,
908        );
909        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
910        assert!(
911            matches!(err, ConfigError::DefaultCount { found: 2 }),
912            "expected DefaultCount {{ found: 2 }}, got {err:?}"
913        );
914    }
915
916    #[test]
917    fn test_fusion_weight_validation() {
918        let dir = tempfile::tempdir().unwrap();
919        let path = write_toml(
920            &dir,
921            r#"
922[[engines]]
923name = "a"
924model = "all-minilm-l6-v2"
925default = true
926fusion_weight = -0.5
927"#,
928        );
929        let err =
930            KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
931        assert!(
932            matches!(err, ConfigError::InvalidFusionWeight { .. }),
933            "expected InvalidFusionWeight, got {err:?}"
934        );
935
936        let path2 = write_toml(
937            &dir,
938            r#"
939[[engines]]
940name = "a"
941model = "all-minilm-l6-v2"
942default = true
943fusion_weight = 0.0
944"#,
945        );
946        let err2 =
947            KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
948        assert!(
949            matches!(err2, ConfigError::InvalidFusionWeight { .. }),
950            "expected InvalidFusionWeight, got {err2:?}"
951        );
952    }
953
954    #[test]
955    fn test_env_var_fallback() {
956        let dir = tempfile::tempdir().unwrap();
957        let absent = dir.path().join("missing.toml");
958
959        let loaded = KhiveConfig::load(Some(&absent)).unwrap();
960        assert!(loaded.is_none());
961
962        // Can't safely set env vars in a parallel test suite, so exercise the
963        // direct construction path instead.
964        let primary = "all-minilm-l6-v2".to_string();
965        let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
966
967        let mut engines = vec![EngineConfig {
968            name: "default".to_string(),
969            model: primary,
970            default: true,
971            fusion_weight: None,
972            dims: None,
973        }];
974        for (i, model) in additional.into_iter().enumerate() {
975            engines.push(EngineConfig {
976                name: format!("engine-{}", i + 1),
977                model,
978                default: false,
979                fusion_weight: None,
980                dims: None,
981            });
982        }
983        let cfg = KhiveConfig {
984            engines,
985            ..KhiveConfig::default()
986        };
987        cfg.validate().expect("env-derived config should be valid");
988        assert_eq!(cfg.engines.len(), 2);
989        assert!(cfg.default_engine().is_some());
990        assert_eq!(cfg.default_engine().unwrap().name, "default");
991    }
992
993    #[test]
994    fn test_file_overrides_env() {
995        let dir = tempfile::tempdir().unwrap();
996        let path = write_toml(
997            &dir,
998            r#"
999[[engines]]
1000name = "file-engine"
1001model = "all-minilm-l6-v2"
1002default = true
1003"#,
1004        );
1005
1006        // KhiveConfig::load returns the file config regardless of env vars;
1007        // warning-on-conflict is the caller's responsibility.
1008        let cfg = KhiveConfig::load(Some(&path))
1009            .expect("load should succeed")
1010            .expect("file should be present");
1011        assert_eq!(cfg.engines[0].name, "file-engine");
1012    }
1013
1014    #[test]
1015    fn test_duplicate_engine_names_rejected() {
1016        let dir = tempfile::tempdir().unwrap();
1017        let path = write_toml(
1018            &dir,
1019            r#"
1020[[engines]]
1021name = "shared"
1022model = "all-minilm-l6-v2"
1023default = true
1024
1025[[engines]]
1026name = "shared"
1027model = "paraphrase-multilingual-minilm-l12-v2"
1028"#,
1029        );
1030        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1031        assert!(
1032            matches!(err, ConfigError::DuplicateName { .. }),
1033            "expected DuplicateName, got {err:?}"
1034        );
1035    }
1036
1037    #[test]
1038    fn test_empty_config_is_valid() {
1039        let dir = tempfile::tempdir().unwrap();
1040        let path = write_toml(&dir, "# no engines\n");
1041        let cfg = KhiveConfig::load(Some(&path))
1042            .expect("load should succeed")
1043            .expect("file should be found");
1044        assert!(cfg.engines.is_empty());
1045        cfg.validate().expect("empty config should be valid");
1046    }
1047
1048    #[test]
1049    fn test_multi_engine_positive_fusion_weight() {
1050        let dir = tempfile::tempdir().unwrap();
1051        let path = write_toml(
1052            &dir,
1053            r#"
1054[[engines]]
1055name = "primary"
1056model = "all-minilm-l6-v2"
1057default = true
1058fusion_weight = 0.7
1059
1060[[engines]]
1061name = "secondary"
1062model = "paraphrase-multilingual-minilm-l12-v2"
1063fusion_weight = 0.3
1064"#,
1065        );
1066        let cfg = KhiveConfig::load(Some(&path))
1067            .expect("load should succeed")
1068            .expect("file should be found");
1069        assert_eq!(cfg.engines.len(), 2);
1070        assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
1071        assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
1072    }
1073
1074    #[test]
1075    fn test_actor_id_parsed() {
1076        let dir = tempfile::tempdir().unwrap();
1077        let path = write_toml(
1078            &dir,
1079            r#"
1080[actor]
1081id = "lambda:khive"
1082display_name = "example actor"
1083"#,
1084        );
1085        let cfg = KhiveConfig::load(Some(&path))
1086            .expect("load should succeed")
1087            .expect("file should be found");
1088        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
1089        assert_eq!(cfg.actor.display_name.as_deref(), Some("example actor"));
1090        assert!(cfg.engines.is_empty());
1091    }
1092
1093    #[test]
1094    fn test_actor_and_engines_together() {
1095        let dir = tempfile::tempdir().unwrap();
1096        let path = write_toml(
1097            &dir,
1098            r#"
1099[actor]
1100id = "lambda:test"
1101
1102[[engines]]
1103name = "default"
1104model = "all-minilm-l6-v2"
1105default = true
1106"#,
1107        );
1108        let cfg = KhiveConfig::load(Some(&path))
1109            .expect("load should succeed")
1110            .expect("file should be found");
1111        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
1112        assert_eq!(cfg.engines.len(), 1);
1113    }
1114
1115    #[test]
1116    fn test_actor_absent_defaults_to_none() {
1117        let dir = tempfile::tempdir().unwrap();
1118        let path = write_toml(
1119            &dir,
1120            r#"
1121[[engines]]
1122name = "x"
1123model = "all-minilm-l6-v2"
1124default = true
1125"#,
1126        );
1127        let cfg = KhiveConfig::load(Some(&path))
1128            .expect("load should succeed")
1129            .expect("file should be found");
1130        assert!(
1131            cfg.actor.id.is_none(),
1132            "actor.id must be None when [actor] section is absent"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_load_with_home_fallback_no_files() {
1138        let project_dir = tempfile::tempdir().unwrap();
1139        let home_dir = tempfile::tempdir().unwrap();
1140        let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None);
1141        assert!(
1142            result.expect("no error expected").is_none(),
1143            "should return None when no config files exist in the given roots"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_load_with_home_fallback_explicit_path() {
1149        let dir = tempfile::tempdir().unwrap();
1150        let path = write_toml(
1151            &dir,
1152            r#"
1153[actor]
1154id = "lambda:explicit"
1155"#,
1156        );
1157        let cfg = KhiveConfig::load_with_home_fallback(Some(&path), None)
1158            .expect("no error expected")
1159            .expect("file found");
1160        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
1161    }
1162
1163    #[test]
1164    fn test_invalid_actor_id_rejected_at_load() {
1165        let dir = tempfile::tempdir().unwrap();
1166        let path = write_toml(
1167            &dir,
1168            r#"
1169[actor]
1170id = "bad namespace"
1171"#,
1172        );
1173        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
1174        assert!(
1175            matches!(err, ConfigError::InvalidActorId { .. }),
1176            "expected InvalidActorId, got {err:?}"
1177        );
1178    }
1179
1180    #[test]
1181    fn test_empty_actor_id_rejected() {
1182        let dir = tempfile::tempdir().unwrap();
1183        let path = write_toml(
1184            &dir,
1185            r#"
1186[actor]
1187id = ""
1188"#,
1189        );
1190        let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
1191        assert!(
1192            matches!(err, ConfigError::InvalidActorId { .. }),
1193            "expected InvalidActorId for empty string, got {err:?}"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_malformed_actor_id_lambda_colon_only() {
1199        let dir = tempfile::tempdir().unwrap();
1200        let path = write_toml(
1201            &dir,
1202            r#"
1203[actor]
1204id = "lambda:"
1205"#,
1206        );
1207        let err =
1208            KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
1209        assert!(
1210            matches!(err, ConfigError::InvalidActorId { .. }),
1211            "expected InvalidActorId for 'lambda:', got {err:?}"
1212        );
1213    }
1214
1215    // actor.id must not become default_namespace: writes stay pinned to `local`
1216    // even though a non-local actor.id widens the default read visible-set.
1217    #[test]
1218    fn test_runtime_config_actor_id_does_not_override_namespace() {
1219        use crate::runtime::runtime_config_from_khive_config;
1220        use crate::RuntimeConfig;
1221        use khive_types::namespace::Namespace;
1222
1223        let cfg = KhiveConfig {
1224            engines: vec![],
1225            actor: ActorConfig {
1226                id: Some("lambda:test-actor".to_string()),
1227                display_name: None,
1228                ..Default::default()
1229            },
1230            ..KhiveConfig::default()
1231        };
1232        cfg.validate().expect("valid config");
1233
1234        let base = RuntimeConfig::default();
1235        let result = runtime_config_from_khive_config(&cfg, base);
1236        assert_eq!(
1237            result.default_namespace,
1238            Namespace::local(),
1239            "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
1240             writes stay pinned to local"
1241        );
1242        // actor.id must also appear in visible_namespaces: the load-bearing
1243        // side effect that widens default reads to {local} ∪ {actor namespace}.
1244        assert!(
1245            result
1246                .visible_namespaces
1247                .contains(&Namespace::parse("lambda:test-actor").unwrap()),
1248            "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
1249             got: {:?}",
1250            result.visible_namespaces
1251        );
1252    }
1253
1254    #[test]
1255    fn test_runtime_config_no_actor_preserves_base() {
1256        use crate::runtime::runtime_config_from_khive_config;
1257        use crate::RuntimeConfig;
1258        use khive_types::namespace::Namespace;
1259
1260        let cfg = KhiveConfig {
1261            engines: vec![],
1262            actor: ActorConfig {
1263                id: None,
1264                display_name: None,
1265                ..Default::default()
1266            },
1267            ..KhiveConfig::default()
1268        };
1269        cfg.validate().expect("valid config");
1270
1271        let base_ns = Namespace::parse("lambda:base").unwrap();
1272        let base = RuntimeConfig {
1273            default_namespace: base_ns.clone(),
1274            ..RuntimeConfig::default()
1275        };
1276        let result = runtime_config_from_khive_config(&cfg, base);
1277        assert_eq!(
1278            result.default_namespace, base_ns,
1279            "no actor.id must leave base namespace unchanged"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_load_with_home_fallback_project_root_over_hidden() {
1285        let dir = tempfile::tempdir().unwrap();
1286
1287        // Write .khive/config.toml (tier 3).
1288        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1289        std::fs::write(
1290            dir.path().join(".khive/config.toml"),
1291            "[actor]\nid = \"lambda:hidden\"\n",
1292        )
1293        .unwrap();
1294
1295        // Write khive.toml (tier 2) — should win.
1296        std::fs::write(
1297            dir.path().join("khive.toml"),
1298            "[actor]\nid = \"lambda:project-root\"\n",
1299        )
1300        .unwrap();
1301
1302        let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1303            .expect("no error expected")
1304            .expect("file should be found");
1305        assert_eq!(
1306            cfg.actor.id.as_deref(),
1307            Some("lambda:project-root"),
1308            "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_load_with_home_fallback_hidden_over_absent_root() {
1314        let dir = tempfile::tempdir().unwrap();
1315
1316        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1317        std::fs::write(
1318            dir.path().join(".khive/config.toml"),
1319            "[actor]\nid = \"lambda:hidden-config\"\n",
1320        )
1321        .unwrap();
1322        // No khive.toml.
1323
1324        let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1325            .expect("no error expected")
1326            .expect("file should be found");
1327        assert_eq!(
1328            cfg.actor.id.as_deref(),
1329            Some("lambda:hidden-config"),
1330            ".khive/config.toml (tier 3) must be found when khive.toml is absent"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_load_with_roots_home_tier_found() {
1336        let project_dir = tempfile::tempdir().unwrap();
1337        let home_dir = tempfile::tempdir().unwrap();
1338
1339        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1340        std::fs::write(
1341            home_dir.path().join(".khive/config.toml"),
1342            "[actor]\nid = \"lambda:user-global\"\n",
1343        )
1344        .unwrap();
1345        // No project-level files.
1346
1347        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1348            .expect("no error expected")
1349            .expect("file should be found");
1350        assert_eq!(
1351            cfg.actor.id.as_deref(),
1352            Some("lambda:user-global"),
1353            "~/.khive/config.toml (tier 4) must be found when project files absent"
1354        );
1355    }
1356
1357    #[test]
1358    fn test_load_with_roots_project_wins_over_home() {
1359        let project_dir = tempfile::tempdir().unwrap();
1360        let home_dir = tempfile::tempdir().unwrap();
1361
1362        // Home has a config.
1363        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1364        std::fs::write(
1365            home_dir.path().join(".khive/config.toml"),
1366            "[actor]\nid = \"lambda:user-global\"\n",
1367        )
1368        .unwrap();
1369
1370        // Project also has a config — should win.
1371        std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
1372        std::fs::write(
1373            project_dir.path().join(".khive/config.toml"),
1374            "[actor]\nid = \"lambda:project-wins\"\n",
1375        )
1376        .unwrap();
1377
1378        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1379            .expect("no error expected")
1380            .expect("file should be found");
1381        assert_eq!(
1382            cfg.actor.id.as_deref(),
1383            Some("lambda:project-wins"),
1384            "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
1385        );
1386    }
1387
1388    // ── tier-3 db-dir anchor tests (config discovery canonicalization) ─────
1389
1390    // Two different process working directories, targeting the same database,
1391    // must resolve the identical tier-3 config file. Each cwd also carries its
1392    // own decoy `.khive/config.toml` so the test fails loudly (mismatched
1393    // actor ids) if the resolver ever falls back to the old cwd anchor instead
1394    // of the db-dir anchor.
1395    #[test]
1396    fn test_load_with_roots_same_db_different_cwd_resolves_identical_config() {
1397        let cwd_a = tempfile::tempdir().unwrap();
1398        let cwd_b = tempfile::tempdir().unwrap();
1399
1400        // Decoy cwd-anchored configs — must NOT be picked up once anchoring
1401        // moves to the db directory.
1402        std::fs::create_dir_all(cwd_a.path().join(".khive")).unwrap();
1403        std::fs::write(
1404            cwd_a.path().join(".khive/config.toml"),
1405            "[actor]\nid = \"lambda:wrong-cwd-a\"\n",
1406        )
1407        .unwrap();
1408        std::fs::create_dir_all(cwd_b.path().join(".khive")).unwrap();
1409        std::fs::write(
1410            cwd_b.path().join(".khive/config.toml"),
1411            "[actor]\nid = \"lambda:wrong-cwd-b\"\n",
1412        )
1413        .unwrap();
1414
1415        // The database and its co-located config live under a THIRD root,
1416        // distinct from either simulated cwd.
1417        let db_root = tempfile::tempdir().unwrap();
1418        let khive_dir = db_root.path().join(".khive");
1419        std::fs::create_dir_all(&khive_dir).unwrap();
1420        let db_path = khive_dir.join("khive.db");
1421        std::fs::write(&db_path, b"").unwrap(); // must exist for canonicalize to succeed
1422        std::fs::write(
1423            khive_dir.join("config.toml"),
1424            "[actor]\nid = \"lambda:db-anchored\"\n",
1425        )
1426        .unwrap();
1427
1428        let cfg_a = KhiveConfig::load_with_roots(cwd_a.path(), None, Some(&db_path))
1429            .expect("no error expected")
1430            .expect("db-anchored config must be found from cwd A");
1431        let cfg_b = KhiveConfig::load_with_roots(cwd_b.path(), None, Some(&db_path))
1432            .expect("no error expected")
1433            .expect("db-anchored config must be found from cwd B");
1434
1435        assert_eq!(
1436            cfg_a.actor.id.as_deref(),
1437            Some("lambda:db-anchored"),
1438            "cwd A must resolve the db-anchored config, not its own decoy"
1439        );
1440        assert_eq!(
1441            cfg_b.actor.id.as_deref(),
1442            Some("lambda:db-anchored"),
1443            "cwd B must resolve the db-anchored config, not its own decoy"
1444        );
1445        assert_eq!(
1446            cfg_a.actor.id, cfg_b.actor.id,
1447            "two processes at different cwds targeting the same db must resolve \
1448             identical config, killing config_id drift between client and daemon"
1449        );
1450    }
1451
1452    // Explicit `--config`/`KHIVE_CONFIG` (tier 1) must still win over the new
1453    // db-dir anchor (tier 3) — precedence is preserved, only the tier-3 anchor
1454    // moved.
1455    #[test]
1456    fn test_load_with_home_fallback_explicit_config_wins_over_db_anchor() {
1457        let explicit_dir = tempfile::tempdir().unwrap();
1458        let explicit_path = write_toml(&explicit_dir, "[actor]\nid = \"lambda:explicit-wins\"\n");
1459
1460        let db_root = tempfile::tempdir().unwrap();
1461        let khive_dir = db_root.path().join(".khive");
1462        std::fs::create_dir_all(&khive_dir).unwrap();
1463        let db_path = khive_dir.join("khive.db");
1464        std::fs::write(&db_path, b"").unwrap();
1465        std::fs::write(
1466            khive_dir.join("config.toml"),
1467            "[actor]\nid = \"lambda:db-anchor-loses\"\n",
1468        )
1469        .unwrap();
1470
1471        let cfg = KhiveConfig::load_with_home_fallback(Some(&explicit_path), Some(&db_path))
1472            .expect("no error expected")
1473            .expect("explicit path must be found");
1474        assert_eq!(
1475            cfg.actor.id.as_deref(),
1476            Some("lambda:explicit-wins"),
1477            "explicit --config/KHIVE_CONFIG must win over the db-dir anchor"
1478        );
1479    }
1480
1481    // Tier 4 (`~/.khive/config.toml`) must still be reached when the db-anchored
1482    // tier-3 directory has no `config.toml` alongside it.
1483    #[test]
1484    fn test_load_with_roots_home_fallback_reached_when_db_anchor_has_no_config() {
1485        let cwd = tempfile::tempdir().unwrap();
1486        let home_dir = tempfile::tempdir().unwrap();
1487        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1488        std::fs::write(
1489            home_dir.path().join(".khive/config.toml"),
1490            "[actor]\nid = \"lambda:home-fallback\"\n",
1491        )
1492        .unwrap();
1493
1494        // A real db directory that exists but has no co-located config.toml.
1495        let db_root = tempfile::tempdir().unwrap();
1496        let khive_dir = db_root.path().join(".khive");
1497        std::fs::create_dir_all(&khive_dir).unwrap();
1498        let db_path = khive_dir.join("khive.db");
1499        std::fs::write(&db_path, b"").unwrap();
1500
1501        let cfg = KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&db_path))
1502            .expect("no error expected")
1503            .expect("home-tier config must be found");
1504        assert_eq!(
1505            cfg.actor.id.as_deref(),
1506            Some("lambda:home-fallback"),
1507            "tier 4 (~/.khive/config.toml) must still be reached when the db-anchored \
1508             tier-3 directory has no config.toml"
1509        );
1510    }
1511
1512    // Cold start: the database file does not exist yet (first run). Anchor
1513    // resolution must not panic and must fall through the remaining tiers.
1514    #[test]
1515    fn test_load_with_roots_nonexistent_db_path_does_not_panic_and_falls_through() {
1516        let cwd = tempfile::tempdir().unwrap();
1517        let home_dir = tempfile::tempdir().unwrap();
1518        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1519        std::fs::write(
1520            home_dir.path().join(".khive/config.toml"),
1521            "[actor]\nid = \"lambda:home-cold-start\"\n",
1522        )
1523        .unwrap();
1524
1525        // Absolute path under a directory tree that was never created.
1526        let nonexistent_db = cwd.path().join("never-created/.khive/khive.db");
1527
1528        let cfg =
1529            KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&nonexistent_db))
1530                .expect("cold-start db path must not error or panic")
1531                .expect("home-tier config must still be found");
1532        assert_eq!(
1533            cfg.actor.id.as_deref(),
1534            Some("lambda:home-cold-start"),
1535            "a nonexistent db path (cold start) must fall through to tier 4, not panic"
1536        );
1537    }
1538
1539    // Cold start with a *relative* nonexistent db path exercises the
1540    // cwd-join fallback branch specifically (as opposed to the
1541    // already-absolute fallback branch above). Must not panic; no config
1542    // exists anywhere so the result is `Ok(None)`.
1543    #[test]
1544    fn test_load_with_roots_relative_nonexistent_db_path_does_not_panic() {
1545        let cwd = tempfile::tempdir().unwrap();
1546        let relative_db = PathBuf::from("never-created/.khive/khive.db");
1547
1548        let result = KhiveConfig::load_with_roots(cwd.path(), None, Some(&relative_db));
1549        assert!(
1550            result.is_ok(),
1551            "relative cold-start db path must not error or panic: {result:?}"
1552        );
1553        assert!(
1554            result.unwrap().is_none(),
1555            "no config exists anywhere in this test; result must be None"
1556        );
1557    }
1558
1559    // ── ADR-028 backend / pack config tests ─────────────────────────────────
1560
1561    #[test]
1562    fn test_no_backends_section_is_valid() {
1563        let dir = tempfile::tempdir().unwrap();
1564        let path = write_toml(
1565            &dir,
1566            r#"
1567[[engines]]
1568name = "default"
1569model = "all-minilm-l6-v2"
1570default = true
1571"#,
1572        );
1573        let cfg = KhiveConfig::load(Some(&path))
1574            .expect("no error")
1575            .expect("file found");
1576        assert!(cfg.backends.is_empty());
1577        assert!(cfg.packs.is_empty());
1578    }
1579
1580    #[test]
1581    fn test_single_sqlite_backend_parses() {
1582        let dir = tempfile::tempdir().unwrap();
1583        let path = write_toml(
1584            &dir,
1585            r#"
1586[[backends]]
1587name = "knowledge"
1588kind = "sqlite"
1589path = "/tmp/knowledge.db"
1590"#,
1591        );
1592        let cfg = KhiveConfig::load(Some(&path))
1593            .expect("no error")
1594            .expect("file found");
1595        assert_eq!(cfg.backends.len(), 1);
1596        let b = &cfg.backends[0];
1597        assert_eq!(b.name, "knowledge");
1598        assert!(matches!(b.kind, BackendKind::Sqlite));
1599        assert_eq!(
1600            b.path.as_ref().and_then(|p| p.to_str()),
1601            Some("/tmp/knowledge.db")
1602        );
1603    }
1604
1605    #[test]
1606    fn test_memory_backend_parses() {
1607        let dir = tempfile::tempdir().unwrap();
1608        let path = write_toml(
1609            &dir,
1610            r#"
1611[[backends]]
1612name = "ephemeral"
1613kind = "memory"
1614"#,
1615        );
1616        let cfg = KhiveConfig::load(Some(&path))
1617            .expect("no error")
1618            .expect("file found");
1619        assert_eq!(cfg.backends.len(), 1);
1620        assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
1621    }
1622
1623    #[test]
1624    fn test_pack_backend_assignment_parses() {
1625        let dir = tempfile::tempdir().unwrap();
1626        let path = write_toml(
1627            &dir,
1628            r#"
1629[[backends]]
1630name = "knowledge"
1631kind = "memory"
1632
1633[packs.knowledge]
1634backend = "knowledge"
1635"#,
1636        );
1637        let cfg = KhiveConfig::load(Some(&path))
1638            .expect("no error")
1639            .expect("file found");
1640        assert_eq!(cfg.packs.len(), 1);
1641        let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
1642        assert_eq!(pc.backend, "knowledge");
1643    }
1644
1645    #[test]
1646    fn test_duplicate_backend_name_rejected() {
1647        let dir = tempfile::tempdir().unwrap();
1648        let path = write_toml(
1649            &dir,
1650            r#"
1651[[backends]]
1652name = "dup"
1653kind = "memory"
1654
1655[[backends]]
1656name = "dup"
1657kind = "memory"
1658"#,
1659        );
1660        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1661        assert!(
1662            matches!(err, ConfigError::DuplicateBackendName { ref name } if name == "dup"),
1663            "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
1664        );
1665    }
1666
1667    #[test]
1668    fn test_pack_referencing_undefined_backend_rejected() {
1669        let dir = tempfile::tempdir().unwrap();
1670        let path = write_toml(
1671            &dir,
1672            r#"
1673[[backends]]
1674name = "knowledge"
1675kind = "memory"
1676
1677[packs.kg]
1678backend = "nonexistent"
1679"#,
1680        );
1681        let err =
1682            KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
1683        assert!(
1684            matches!(err, ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
1685                if pack == "kg" && backend == "nonexistent"),
1686            "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
1687        );
1688    }
1689
1690    #[test]
1691    fn test_pack_config_without_backends_section_is_allowed() {
1692        let dir = tempfile::tempdir().unwrap();
1693        // When [[backends]] is absent/empty, packs are not validated: all
1694        // packs fall through to the implicit main backend.
1695        let path = write_toml(
1696            &dir,
1697            r#"
1698[packs.kg]
1699backend = "main"
1700"#,
1701        );
1702        let cfg = KhiveConfig::load(Some(&path))
1703            .expect("no error expected")
1704            .expect("file found");
1705        assert_eq!(cfg.backends.len(), 0);
1706        assert_eq!(cfg.packs.len(), 1);
1707    }
1708
1709    #[test]
1710    fn test_backend_cache_mb_rejected_at_validate() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let path = write_toml(
1713            &dir,
1714            r#"
1715[[backends]]
1716name = "main"
1717kind = "memory"
1718cache_mb = 128
1719"#,
1720        );
1721        let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
1722        assert!(
1723            matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
1724            "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
1725        );
1726    }
1727
1728    #[test]
1729    fn test_backend_journal_mode_rejected_at_validate() {
1730        let dir = tempfile::tempdir().unwrap();
1731        let path = write_toml(
1732            &dir,
1733            r#"
1734[[backends]]
1735name = "main"
1736kind = "memory"
1737journal_mode = "wal"
1738"#,
1739        );
1740        let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
1741        assert!(
1742            matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
1743            "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
1744        );
1745    }
1746
1747    // A top-level `db` key must be rejected loudly instead of silently
1748    // ignored as an unknown key by serde's forward-compatible default.
1749    #[test]
1750    fn test_top_level_db_rejected_at_validate() {
1751        let dir = tempfile::tempdir().unwrap();
1752        let path = write_toml(
1753            &dir,
1754            r#"
1755db = "/tmp/scratch/demo.db"
1756"#,
1757        );
1758        let err = KhiveConfig::load(Some(&path)).expect_err("top-level db must be rejected");
1759        assert!(
1760            matches!(err, ConfigError::UnsupportedTopLevelDb { ref value } if value == "/tmp/scratch/demo.db"),
1761            "expected UnsupportedTopLevelDb {{ value: \"/tmp/scratch/demo.db\" }}, got {err:?}"
1762        );
1763    }
1764
1765    // ── [git_write] section (ADR-108 Amendment) ─────────────────────────────
1766
1767    // No [git_write] section at all -> empty allowlist, valid config.
1768    #[test]
1769    fn test_no_git_write_section_is_valid_and_empty() {
1770        let dir = tempfile::tempdir().unwrap();
1771        let path = write_toml(&dir, "# no git_write section\n");
1772        let cfg = KhiveConfig::load(Some(&path))
1773            .expect("no error")
1774            .expect("file found");
1775        assert!(cfg.git_write.allowed.is_empty());
1776    }
1777
1778    // A well-formed [[git_write.allowed]] entry parses correctly.
1779    #[test]
1780    fn test_git_write_entry_parses() {
1781        let dir = tempfile::tempdir().unwrap();
1782        let path = write_toml(
1783            &dir,
1784            r#"
1785[[git_write.allowed]]
1786repo = "/abs/path/repo"
1787branches = ["feat/*", "fix/*"]
1788"#,
1789        );
1790        let cfg = KhiveConfig::load(Some(&path))
1791            .expect("no error")
1792            .expect("file found");
1793        assert_eq!(cfg.git_write.allowed.len(), 1);
1794        assert_eq!(cfg.git_write.allowed[0].repo, "/abs/path/repo");
1795        assert_eq!(
1796            cfg.git_write.allowed[0].branches,
1797            vec!["feat/*".to_string(), "fix/*".to_string()]
1798        );
1799    }
1800
1801    // A relative repo path is rejected at validate() time.
1802    #[test]
1803    fn test_git_write_relative_repo_rejected() {
1804        let dir = tempfile::tempdir().unwrap();
1805        let path = write_toml(
1806            &dir,
1807            r#"
1808[[git_write.allowed]]
1809repo = "relative/path"
1810branches = ["main"]
1811"#,
1812        );
1813        let err = KhiveConfig::load(Some(&path)).expect_err("relative repo must be rejected");
1814        assert!(
1815            matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "relative/path"),
1816            "expected InvalidGitWriteEntry, got {err:?}"
1817        );
1818    }
1819
1820    // ADR-108: a branch pattern with more than one `*` is rejected at
1821    // validate() time -- the ADR authorizes exact-name or single-wildcard
1822    // patterns only.
1823    #[test]
1824    fn test_git_write_multi_star_branch_pattern_rejected() {
1825        let dir = tempfile::tempdir().unwrap();
1826        let path = write_toml(
1827            &dir,
1828            r#"
1829[[git_write.allowed]]
1830repo = "/abs/path"
1831branches = ["**"]
1832"#,
1833        );
1834        let err = KhiveConfig::load(Some(&path)).expect_err("** must be rejected");
1835        assert!(
1836            matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
1837            "expected InvalidGitWriteEntry, got {err:?}"
1838        );
1839
1840        let dir2 = tempfile::tempdir().unwrap();
1841        let path2 = write_toml(
1842            &dir2,
1843            r#"
1844[[git_write.allowed]]
1845repo = "/abs/path"
1846branches = ["rel-*-*-final"]
1847"#,
1848        );
1849        let err2 = KhiveConfig::load(Some(&path2)).expect_err("rel-*-*-final must be rejected");
1850        assert!(
1851            matches!(err2, ConfigError::InvalidGitWriteEntry { .. }),
1852            "expected InvalidGitWriteEntry, got {err2:?}"
1853        );
1854    }
1855
1856    // Single-wildcard patterns remain accepted.
1857    #[test]
1858    fn test_git_write_single_star_branch_pattern_accepted() {
1859        let dir = tempfile::tempdir().unwrap();
1860        let path = write_toml(
1861            &dir,
1862            r#"
1863[[git_write.allowed]]
1864repo = "/abs/path"
1865branches = ["a*b", "main"]
1866"#,
1867        );
1868        let cfg = KhiveConfig::load(Some(&path))
1869            .expect("no error")
1870            .expect("file found");
1871        assert_eq!(cfg.git_write.allowed[0].branches, vec!["a*b", "main"]);
1872    }
1873
1874    // An entry with an empty branches list is rejected at validate() time --
1875    // it would otherwise silently allowlist a repo for no branch at all.
1876    #[test]
1877    fn test_git_write_empty_branches_rejected() {
1878        let dir = tempfile::tempdir().unwrap();
1879        let path = write_toml(
1880            &dir,
1881            r#"
1882[[git_write.allowed]]
1883repo = "/abs/path"
1884branches = []
1885"#,
1886        );
1887        let err = KhiveConfig::load(Some(&path)).expect_err("empty branches must be rejected");
1888        assert!(
1889            matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
1890            "expected InvalidGitWriteEntry, got {err:?}"
1891        );
1892    }
1893
1894    // ── [storage.blob] section (ADR-111 Amendment 2) ─────────────────────────
1895
1896    // No [storage] section at all -> fs default, existing configurations
1897    // keep behaving exactly as they did before this section existed.
1898    #[test]
1899    fn test_no_storage_section_defaults_to_fs() {
1900        let dir = tempfile::tempdir().unwrap();
1901        let path = write_toml(&dir, "# no storage section\n");
1902        let cfg = KhiveConfig::load(Some(&path))
1903            .expect("no error")
1904            .expect("file found");
1905        assert!(cfg.storage.blob.is_none());
1906    }
1907
1908    #[test]
1909    fn test_storage_blob_fs_selection_parses() {
1910        let dir = tempfile::tempdir().unwrap();
1911        let path = write_toml(
1912            &dir,
1913            r#"
1914[storage.blob]
1915backend = "fs"
1916root = "/var/lib/khive/blobs"
1917floor_bytes = 100000000000
1918"#,
1919        );
1920        let cfg = KhiveConfig::load(Some(&path))
1921            .expect("no error")
1922            .expect("file found");
1923        match cfg.storage.blob {
1924            Some(BlobConfig::Fs { root, floor_bytes }) => {
1925                assert_eq!(root.as_deref(), Some("/var/lib/khive/blobs"));
1926                assert_eq!(floor_bytes, Some(100_000_000_000));
1927            }
1928            other => panic!("expected BlobConfig::Fs, got {other:?}"),
1929        }
1930    }
1931
1932    #[test]
1933    fn test_storage_blob_s3_selection_parses() {
1934        let dir = tempfile::tempdir().unwrap();
1935        let path = write_toml(
1936            &dir,
1937            r#"
1938[storage.blob]
1939backend = "s3"
1940bucket = "khive-blobs"
1941region = "us-east-1"
1942endpoint = "https://objects.example.invalid"
1943prefix = "blobs"
1944"#,
1945        );
1946        let cfg = KhiveConfig::load(Some(&path))
1947            .expect("no error")
1948            .expect("file found");
1949        match cfg.storage.blob {
1950            Some(BlobConfig::S3 {
1951                bucket,
1952                region,
1953                endpoint,
1954                prefix,
1955                allow_http,
1956            }) => {
1957                assert_eq!(bucket, "khive-blobs");
1958                assert_eq!(region, "us-east-1");
1959                assert_eq!(endpoint.as_deref(), Some("https://objects.example.invalid"));
1960                assert_eq!(prefix.as_deref(), Some("blobs"));
1961                assert_eq!(allow_http, None);
1962            }
1963            other => panic!("expected BlobConfig::S3, got {other:?}"),
1964        }
1965    }
1966
1967    // An unknown field under [storage.blob] must be a startup error, not
1968    // silently ignored -- unlike the rest of KhiveConfig, this section is
1969    // strict (deny_unknown_fields).
1970    #[test]
1971    fn test_storage_blob_unknown_field_rejected() {
1972        let dir = tempfile::tempdir().unwrap();
1973        let path = write_toml(
1974            &dir,
1975            r#"
1976[storage.blob]
1977backend = "fs"
1978made_up_field = "x"
1979"#,
1980        );
1981        let err = KhiveConfig::load(Some(&path)).expect_err("unknown field must be rejected");
1982        assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
1983    }
1984
1985    // An s3-only field (bucket) under backend = "fs" must be rejected: the
1986    // internally tagged enum's Fs variant doesn't declare it, so it is an
1987    // unknown field for that variant.
1988    #[test]
1989    fn test_storage_blob_other_backend_field_rejected() {
1990        let dir = tempfile::tempdir().unwrap();
1991        let path = write_toml(
1992            &dir,
1993            r#"
1994[storage.blob]
1995backend = "fs"
1996bucket = "khive-blobs"
1997"#,
1998        );
1999        let err = KhiveConfig::load(Some(&path)).expect_err("s3 field under fs must be rejected");
2000        assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
2001    }
2002
2003    // Credentials are never accepted in TOML (ADR-111 Amendment 2): an
2004    // access-key field under backend = "s3" is unknown to that variant and
2005    // must be rejected, the same way an other-backend field is.
2006    #[test]
2007    fn test_storage_blob_credential_field_rejected() {
2008        let dir = tempfile::tempdir().unwrap();
2009        let path = write_toml(
2010            &dir,
2011            r#"
2012[storage.blob]
2013backend = "s3"
2014bucket = "khive-blobs"
2015region = "us-east-1"
2016access_key_id = "AKIAEXAMPLE"
2017"#,
2018        );
2019        let err = KhiveConfig::load(Some(&path))
2020            .expect_err("a credential field in TOML must be rejected");
2021        assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
2022    }
2023
2024    // An unrecognized backend value is rejected by the internally tagged
2025    // enum's own tag matching, same mechanism as an unknown field.
2026    #[test]
2027    fn test_storage_blob_unknown_backend_value_rejected() {
2028        let dir = tempfile::tempdir().unwrap();
2029        let path = write_toml(
2030            &dir,
2031            r#"
2032[storage.blob]
2033backend = "gcs"
2034"#,
2035        );
2036        let err = KhiveConfig::load(Some(&path)).expect_err("unknown backend must be rejected");
2037        assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
2038    }
2039}