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::{
15    config::{parse_embedding_model_alias, BackendId},
16    presentation::OutputFormat,
17};
18
19// ---- Error type ----
20
21/// Errors produced while loading or validating a `KhiveConfig`.
22#[derive(Debug, Error)]
23pub enum ConfigError {
24    #[error("mount configuration: {reason}")]
25    InvalidMountConfig { reason: String },
26
27    #[error("config file I/O: {0}")]
28    Io(#[from] std::io::Error),
29
30    #[error("config TOML parse error in {path}: {source}")]
31    Parse {
32        path: PathBuf,
33        #[source]
34        source: toml::de::Error,
35    },
36
37    #[error("exactly one engine must be marked `default = true`; found {found}")]
38    DefaultCount { found: usize },
39
40    #[error("duplicate engine name: {name:?}")]
41    DuplicateName { name: String },
42
43    #[error(
44        "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
45    )]
46    UnknownModel { name: String, model: String },
47
48    #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
49    InvalidFusionWeight { name: String, value: f64 },
50
51    #[error("actor.id {id:?} is not a valid namespace: {reason}")]
52    InvalidActorId { id: String, reason: String },
53
54    #[error("[actor].mailbox_readers: {reason}")]
55    InvalidMailboxReaders { reason: String },
56
57    #[error("[gate].granted_actors entry {id:?} is not a valid actor id: {reason}")]
58    InvalidGrantedActorId { id: String, reason: String },
59    #[error("[gate].deny_writes_for is invalid: {reason}")]
60    InvalidWriteDenyPatterns { reason: String },
61
62    #[error("duplicate backend name: {name:?}")]
63    DuplicateBackendName { name: String },
64
65    #[error("invalid backend name {name:?}: {reason}")]
66    InvalidBackendName { name: String, reason: String },
67
68    #[error("backend {name:?}: `served_kinds` must not be empty when declared")]
69    EmptyBackendServedKinds { name: String },
70
71    #[error(
72        "backend configuration leaves searchable substrate kinds {kinds:?} unserved; \
73         defined backends: {defined}"
74    )]
75    MissingBackendSearchKinds {
76        kinds: Vec<SubstrateKind>,
77        defined: String,
78    },
79
80    #[error(
81        "[packs.{pack}].backend = {backend:?} references an unknown backend; \
82         defined backends: {defined}"
83    )]
84    UnknownPackBackend {
85        pack: String,
86        backend: String,
87        defined: String,
88    },
89
90    #[error(
91        "[[backends]] entry {name:?}: field `{field}` is not yet supported; \
92         remove it from the config or wait for a future release that implements it"
93    )]
94    UnsupportedBackendField { name: String, field: &'static str },
95
96    #[error(
97        "top-level `db = {value:?}` is not a supported config-file key; \
98         use `--db` / `KHIVE_DB` to select a single-file database, or \
99         `[[backends]].path` to declare storage backend topology"
100    )]
101    UnsupportedTopLevelDb { value: String },
102
103    #[error("[[git_write.allowed]] entry {repo:?}: {reason}")]
104    InvalidGitWriteEntry { repo: String, reason: String },
105
106    #[error("[git_write] {key}: {reason}")]
107    InvalidGitWriteConfig { key: String, reason: String },
108
109    #[error("[exec] {key}: {reason}")]
110    InvalidExecConfig { key: String, reason: String },
111
112    #[error("{entry}: {reason}")]
113    InvalidTelemetryConfig { entry: String, reason: String },
114
115    #[error("[web] {key}: {reason}")]
116    InvalidWebConfig { key: String, reason: String },
117
118    #[error(
119        "[runtime] blob_hydration_bytes must be between {min} and {max} bytes inclusive; got {value}"
120    )]
121    InvalidBlobHydrationBytes { value: u64, min: u64, max: u64 },
122
123    #[error("the explicitly selected config file does not exist: {path}")]
124    ExplicitConfigMissing { path: PathBuf },
125
126    /// Retained for source compatibility with callers that matched the
127    /// fail-loud behavior of older builds. Supported `[gate]` sections no
128    /// longer produce this error.
129    #[error("[gate] configuration is not supported by this build")]
130    UnsupportedGateSection,
131
132    #[error(
133        "[display] timezone {timezone:?} is not a recognized IANA zone name (e.g. \"America/New_York\", \"UTC\")"
134    )]
135    InvalidDisplayTimezone { timezone: String },
136
137    /// Loader-context wrapper attaching the config file the error came from.
138    ///
139    /// Added as a wrapping variant, rather than reshaping the existing
140    /// variants, so every existing constructor, field access, and the
141    /// `From<std::io::Error>` conversion survive unchanged. The enum is not
142    /// `#[non_exhaustive]`, so an exhaustive `match` on `ConfigError` must
143    /// still add an arm for this variant — either matching `InFile` and
144    /// recursing into `source`, or a wildcard. `Parse` already carries its
145    /// path and is never wrapped.
146    #[error("{source} (config file: {})", path.display())]
147    InFile {
148        path: PathBuf,
149        #[source]
150        source: Box<ConfigError>,
151    },
152}
153
154impl ConfigError {
155    /// Attach the loading config file's path unless the error already names
156    /// one (`Parse`, `ExplicitConfigMissing`) or is already wrapped.
157    fn in_file(self, path: &Path) -> Self {
158        match self {
159            already @ (ConfigError::Parse { .. }
160            | ConfigError::ExplicitConfigMissing { .. }
161            | ConfigError::InFile { .. }) => already,
162            other => ConfigError::InFile {
163                path: path.to_path_buf(),
164                source: Box::new(other),
165            },
166        }
167    }
168}
169
170// ---- Config structs ----
171
172/// Configuration for a single embedding engine.
173#[derive(Debug, Clone, Deserialize)]
174pub struct EngineConfig {
175    /// Logical name used to reference this engine in logs and fusion.
176    pub name: String,
177
178    /// Lattice-embed model name (e.g. `"all-minilm-l6-v2"`).
179    ///
180    /// Must be parseable via `lattice_embed::EmbeddingModel::from_str` (or a
181    /// recognised short alias handled by `parse_embedding_model_alias`).
182    pub model: String,
183
184    /// When `true`, this engine's model becomes the primary (`RuntimeConfig::embedding_model`).
185    /// Exactly one engine in the list must set this. If absent, defaults to `false`.
186    #[serde(default)]
187    pub default: bool,
188
189    /// RRF fusion weight for weighted multi-engine fusion.
190    ///
191    /// Only meaningful when multiple engines are loaded. Must be `> 0` when
192    /// present. `None` means the engine participates in fusion with equal weight
193    /// to other engines that also lack a `fusion_weight`.
194    ///
195    /// For RRF: `fusion_weight` provides per-engine relative importance during
196    /// weighted RRF; it does NOT apply to rank-based unweighted RRF (the weights
197    /// are injected into `FusionStrategy::Weighted` only).
198    pub fusion_weight: Option<f64>,
199
200    /// Expected output dimensionality (optional sanity check).
201    ///
202    /// Not used at runtime — dimensions are authoritative from
203    /// `EmbeddingModel::dimensions()`. Present so operators can document the
204    /// expected shape alongside the model name.
205    pub dims: Option<u32>,
206}
207
208/// Actor configuration — the default namespace / identity for this khive instance.
209///
210/// Corresponds to the `[actor]` TOML section. `id` is used as the
211/// `default_namespace` for gate/attribution policy input. OSS dispatch pins
212/// writes to the shared `local` namespace regardless of this value (ADR-007
213/// Rev 4 Rule 0); cloud deployments derive the namespace from an authenticated
214/// `NamespaceToken` instead.
215///
216/// ```toml
217/// [actor]
218/// id = "lambda:leo"                          # attribution identity (required)
219/// display_name = "example actor"   # human label (optional)
220/// visible_namespaces = ["lambda:khive", "local"]  # widens default read scope (ADR-007 Rev 4 Rule 3b)
221/// ```
222///
223/// `visible_namespaces` is consumed by OSS dispatch to widen the DEFAULT
224/// multi-record read scope to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4
225/// Rule 3b). Writes remain pinned to `'local'`. An explicit `namespace=` request
226/// param is a precise single-namespace escape and is not widened. A cloud gate
227/// may also consult this list as policy input at its own layer.
228///
229/// The table is closed. Both keys above are authorization input, so a misspelled
230/// key fails startup instead of silently applying its default, and a `[gate]` key
231/// written here is reported rather than discarded.
232#[derive(Debug, Clone, Deserialize, Default)]
233#[serde(deny_unknown_fields)]
234pub struct ActorConfig {
235    /// Namespace identifier used as the default actor for all operations.
236    ///
237    /// Must be a valid `Namespace` string (e.g. `"local"`, `"lambda:khive"`).
238    /// Defaults to `"local"` when absent — backward-compatible with pre-actor
239    /// deployments.
240    #[serde(default)]
241    pub id: Option<String>,
242
243    /// Optional human-readable label for this actor. Not used by the runtime;
244    /// surfaced in introspection and log output only.
245    #[serde(default)]
246    pub display_name: Option<String>,
247
248    /// Exact actor labels permitted to inspect this explicit actor's mailbox.
249    ///
250    /// A nonempty list requires an explicit non-local `id`. Labels are bounded
251    /// to 255 bytes, nonblank, and contain no control characters; `local` is
252    /// forbidden. At most 256 entries are accepted before deduplication. This
253    /// is trusted serving-host policy, never inferred from environment identity
254    /// or supplied by a request. Changes take effect in a new server epoch.
255    #[serde(default)]
256    pub mailbox_readers: Vec<String>,
257
258    /// Additional namespaces that widen the DEFAULT multi-record read scope
259    /// to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4 Rule 3b). Each string
260    /// must be a valid `Namespace`. Writes remain pinned to `'local'`. An
261    /// explicit `namespace=` request param is a precise escape and is not widened
262    /// by this list. A cloud gate may also consult it as policy input.
263    #[serde(default)]
264    pub visible_namespaces: Option<Vec<String>>,
265
266    /// Namespaces this actor's comm.send/reply may deliver messages INTO
267    /// (outbound, sender-side). Empty by default — cross-namespace delivery
268    /// denied unless explicitly declared. The comm handler uses an ordinary
269    /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
270    /// the token itself is NOT type-enforced write-only. The recipient-side
271    /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
272    /// a future cloud-path authorization ADR (not yet written).
273    ///
274    /// Each entry must be a valid `Namespace` string; validated at
275    /// config-load time. An empty list preserves the prior deny-all behavior
276    /// for any actor that does not add this field.
277    #[serde(default)]
278    pub allowed_outbound_namespaces: Vec<String>,
279}
280
281/// Built-in caller-enrollment policy configured by `[gate]`.
282///
283/// The table is intentionally closed: misspelled or future keys fail startup
284/// instead of being silently ignored at an authorization boundary. Presence
285/// installs [`khive_gate::CallerEnrollmentGate`]; absence preserves the gate
286/// already supplied in the base [`crate::RuntimeConfig`].
287#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
288#[serde(deny_unknown_fields)]
289pub struct GateSectionConfig {
290    /// Exact resolved actor ids permitted to dispatch requests.
291    #[serde(default)]
292    pub granted_actors: Vec<String>,
293
294    /// Whether the implicit anonymous/local caller is admitted.
295    #[serde(default)]
296    pub grant_unattributed: bool,
297
298    /// Whole actor-ID patterns denying all but explicitly reviewed reads.
299    /// Case-sensitive; only `*` is a wildcard. Does not enroll a caller.
300    #[serde(default)]
301    pub deny_writes_for: Vec<String>,
302}
303
304// ---- Per-pack backend config (ADR-028) ----
305
306/// Storage backend kind.
307#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
308#[serde(rename_all = "lowercase")]
309pub enum BackendKind {
310    /// SQLite file-backed database (default).
311    #[default]
312    Sqlite,
313    /// In-memory database — for testing only; state is lost on restart.
314    Memory,
315}
316
317/// Configuration for a named storage backend.
318///
319/// Corresponds to a `[[backends]]` entry in `khive.toml`.
320/// When no `[[backends]]` section is present, a single implicit `main` backend
321/// is synthesised from the existing `--db` / `KHIVE_DB` / default-path resolution.
322/// All packs fall back to `main` when their name is absent from `[packs]`.
323/// `cache_mb` and `journal_mode` are parsed but rejected during validation
324/// because per-backend tuning is not implemented.
325///
326/// ```toml
327/// [[backends]]
328/// name = "main"
329/// kind = "sqlite"
330/// path = "~/.khive/khive.db"
331/// read_only = false
332/// ```
333#[derive(Debug, Clone, Deserialize)]
334pub struct BackendConfig {
335    /// Unique backend name. Referenced by `[packs.<name>].backend`.
336    pub name: String,
337    /// Storage backend kind. Defaults to `sqlite`.
338    #[serde(default)]
339    pub kind: BackendKind,
340    /// Filesystem path for `sqlite` kind. Tilde is expanded to `$HOME`.
341    /// `None` for `memory` kind (path is ignored when present).
342    pub path: Option<std::path::PathBuf>,
343    /// SQLite page-cache size in MiB. Parsed but rejected as unsupported.
344    pub cache_mb: Option<u32>,
345    /// SQLite journal mode (e.g. `"wal"`). Parsed but rejected as unsupported.
346    pub journal_mode: Option<String>,
347    /// Substrate kinds this backend serves.
348    ///
349    /// Omission preserves conservative fan-out to this backend. An explicit
350    /// declaration is closed over [`SubstrateKind`] and must not be empty.
351    /// The backend set must cover both `note` and `entity` search.
352    #[serde(default)]
353    pub served_kinds: Option<BTreeSet<SubstrateKind>>,
354    /// Open the backend read-only. Defaults to `false`.
355    #[serde(default)]
356    pub read_only: bool,
357}
358
359/// Per-pack backend assignment.
360///
361/// Corresponds to a `[packs.<pack-name>]` entry in `khive.toml`.
362/// Packs whose name is absent from `[packs]` fall back to the `main` backend.
363///
364/// ```toml
365/// [packs.knowledge]
366/// backend = "knowledge"
367///
368/// [packs.comm]
369/// backend = "comm"
370/// no_embed = true
371/// ```
372#[derive(Debug, Clone, Deserialize)]
373pub struct PackConfig {
374    /// Backend name this pack is assigned to. Must match a `[[backends]].name`,
375    /// or `main` when no backends are declared.
376    pub backend: String,
377    /// Disable vector embedding for this pack's runtime: rows it writes get
378    /// FTS and metadata only, no `vec_*` rows and no ANN participation. The
379    /// opt-out covers pack-owned writes on the pack's own backend; it does
380    /// NOT cover `core()`-routed concept writes, which embed with the MAIN
381    /// runtime's embedders (the boot path wires them in via
382    /// `with_core_embedders_from`) so the shared graph stays uniformly
383    /// searchable. Fits packs whose own rows are structural rather than
384    /// retrieval targets (e.g. comm). Effective in multi-backend boot, where
385    /// each pack gets its own runtime. Defaults to `false`.
386    #[serde(default)]
387    pub no_embed: bool,
388}
389
390// ---- Blob store config (ADR-111 Amendment 2) ----
391
392/// `[storage.blob]` section: a closed `backend = "fs" | "s3"` selector.
393///
394/// Internally tagged on `backend` with `deny_unknown_fields`: an unknown
395/// top-level key, a field that belongs to the other backend variant (e.g.
396/// `bucket` under `backend = "fs"`), or an S3 credential field (never
397/// accepted in TOML -- ADR-111 Amendment 2 reads credentials from the
398/// process environment only) are all rejected at config-load time by the
399/// same mechanism, since each variant only declares its own fields.
400///
401/// ```toml
402/// [storage.blob]
403/// backend = "fs"
404/// root = "/var/lib/khive/blobs"
405/// floor_bytes = 100000000000
406/// ```
407///
408/// ```toml
409/// [storage.blob]
410/// backend = "s3"
411/// bucket = "khive-blobs"
412/// region = "us-east-1"
413/// endpoint = "https://objects.example.invalid"
414/// prefix = "blobs"
415/// ```
416#[derive(Debug, Clone, Deserialize)]
417#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
418pub enum BlobConfig {
419    /// Filesystem-backed blob storage (`FsBlobStore`). Root resolution is
420    /// unchanged from khive#292: `KHIVE_BLOB_ROOT` env var, then this
421    /// `root`, then `<db_dir>/blobs`.
422    Fs {
423        #[serde(default)]
424        root: Option<String>,
425        #[serde(default)]
426        floor_bytes: Option<u64>,
427    },
428    /// S3-compatible blob storage (`S3BlobStore`). `KHIVE_BLOB_ROOT` has no
429    /// effect for this backend. Credentials always come from
430    /// `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN` in the
431    /// process environment, never from this section.
432    S3 {
433        bucket: String,
434        region: String,
435        #[serde(default)]
436        endpoint: Option<String>,
437        #[serde(default)]
438        prefix: Option<String>,
439        #[serde(default)]
440        allow_http: Option<bool>,
441    },
442}
443
444/// `[storage]` section in `khive.toml`. Holds storage-layer config not
445/// already covered by `[[backends]]` (ADR-028).
446#[derive(Debug, Clone, Deserialize, Default)]
447pub struct StorageSectionConfig {
448    /// Blob store backend selector (ADR-111 Amendment 2). Absent means
449    /// `FsBlobStore` at the existing root-resolution precedence, unchanged
450    /// from khive#292 -- existing configurations keep behaving exactly as
451    /// they did before this section existed.
452    #[serde(default)]
453    pub blob: Option<BlobConfig>,
454}
455
456/// `[brain]` read policy resolved by the serving process.
457#[derive(Debug, Clone, Deserialize, Serialize, Default)]
458#[serde(deny_unknown_fields)]
459pub struct BrainSectionConfig {
460    /// Actor ids permitted to request fleet-wide `brain.event_counts` reads.
461    #[serde(default)]
462    pub fleet_readers: Vec<String>,
463}
464
465// ---- git-write policy (ADR-108 Amendment) ----
466
467/// One `[[git_write.allowed]]` entry: a repo this operator has declared
468/// trusted for khive-mediated git writes, plus the branches on it a write
469/// verb (`git.commit`/`git.branch`/`git.update_ref`/`git.push`) may target.
470///
471/// ```toml
472/// [[git_write.allowed]]
473/// repo = "/abs/path/repo"
474/// branches = ["feat/*", "fix/*"]
475/// ```
476#[derive(Debug, Clone, Deserialize, Serialize)]
477pub struct GitWriteEntryConfig {
478    /// Absolute local path to the allowlisted repository.
479    pub repo: String,
480    /// Non-empty list of exact branch names or single-`*`-wildcard globs
481    /// this repo entry permits writes against.
482    pub branches: Vec<String>,
483}
484
485/// `[git_write]` section — the closed repo/branch allowlist consulted by
486/// `khive-pack-git`'s write verbs at the handler level (ADR-108 Amendment),
487/// independent of Gate policy. Absent or empty `allowed` is the fail-closed
488/// default: the write verbs report themselves unavailable rather than
489/// defaulting open.
490///
491/// ```toml
492/// [[git_write.allowed]]
493/// repo = "/abs/path/repo"
494/// branches = ["feat/*", "fix/*"]
495/// ```
496#[derive(Debug, Clone, Deserialize, Serialize)]
497pub struct GitWriteSectionConfig {
498    /// Absolute git executable override; absent preserves PATH resolution.
499    #[serde(default)]
500    pub program: Option<PathBuf>,
501    #[serde(default)]
502    pub allowed: Vec<GitWriteEntryConfig>,
503    #[serde(default)]
504    pub actors: BTreeMap<String, GitWriteActorConfig>,
505    #[serde(default)]
506    pub repositories: BTreeMap<String, GitWriteRepositoryConfig>,
507    #[serde(default = "default_git_credential_resolver")]
508    pub credential_resolver: Vec<String>,
509    #[serde(default)]
510    pub contract_faults: bool,
511    #[serde(default)]
512    pub fault: Option<String>,
513}
514
515#[derive(Debug, Clone, Deserialize, Serialize)]
516#[serde(deny_unknown_fields)]
517pub struct GitWriteRepositoryConfig {
518    /// HTTPS platform remote, or an absolute path / file:/// URL with an empty slug.
519    pub remote: String,
520    /// owner/name for HTTPS; empty explicitly opts into credential-free local pushes.
521    pub slug: String,
522    pub visibility: String,
523    /// Merge dispatch refusals for this repository (ADR-182 Amendment 7):
524    /// `opener` refuses a `git.pr_merge` dispatched by the account or actor
525    /// that opened the pull request; `last_pusher` refuses one dispatched by
526    /// the login on the newest push receipt for `expected_head`. Empty (the
527    /// default) refuses neither.
528    #[serde(default)]
529    pub merge_refusals: Vec<String>,
530}
531
532impl GitWriteRepositoryConfig {
533    pub const MERGE_REFUSALS: [&'static str; 2] = ["opener", "last_pusher"];
534
535    /// Whether this repository row lists the named merge refusal.
536    pub fn refuses_merge_by(&self, entry: &str) -> bool {
537        self.merge_refusals.iter().any(|listed| listed == entry)
538    }
539}
540
541#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
542#[serde(deny_unknown_fields)]
543pub struct GitWriteActorConfig {
544    pub name: String,
545    pub email: String,
546    pub credential_ref: String,
547    pub platform_identity: String,
548}
549
550fn default_git_credential_resolver() -> Vec<String> {
551    [
552        "/usr/bin/security",
553        "find-generic-password",
554        "-w",
555        "-s",
556        "{ref}",
557    ]
558    .into_iter()
559    .map(str::to_string)
560    .collect()
561}
562
563impl Default for GitWriteSectionConfig {
564    fn default() -> Self {
565        Self {
566            program: None,
567            allowed: Vec::new(),
568            actors: BTreeMap::new(),
569            repositories: BTreeMap::new(),
570            credential_resolver: default_git_credential_resolver(),
571            contract_faults: false,
572            fault: None,
573        }
574    }
575}
576
577impl GitWriteSectionConfig {
578    pub fn git_program(&self) -> &Path {
579        self.program.as_deref().unwrap_or_else(|| Path::new("git"))
580    }
581
582    pub fn validate_dev_loop(&self) -> Result<(), ConfigError> {
583        let invalid = |key: &str, reason: &str| ConfigError::InvalidGitWriteConfig {
584            key: key.to_string(),
585            reason: reason.to_string(),
586        };
587        if let Some(program) = &self.program {
588            if !program.is_absolute() {
589                return Err(invalid("git_write.program", "must be absolute"));
590            }
591            let metadata = std::fs::metadata(program).map_err(|error| {
592                if error.kind() == std::io::ErrorKind::NotFound {
593                    invalid("git_write.program", "does not exist")
594                } else {
595                    invalid("git_write.program", &format!("is not executable: {error}"))
596                }
597            })?;
598            #[cfg(unix)]
599            let executable = {
600                use std::os::unix::fs::PermissionsExt;
601                metadata.permissions().mode() & 0o111 != 0
602            };
603            #[cfg(windows)]
604            let executable = program
605                .extension()
606                .and_then(|extension| extension.to_str())
607                .is_some_and(|extension| {
608                    extension.eq_ignore_ascii_case("exe") || extension.eq_ignore_ascii_case("com")
609                });
610            #[cfg(not(any(unix, windows)))]
611            let executable = false;
612            if !metadata.is_file() || !executable {
613                return Err(invalid("git_write.program", "is not executable"));
614            }
615        }
616        if self.contract_faults && !cfg!(feature = "contract-faults") {
617            tracing::error!(
618                target: "khive.boot",
619                "[git_write] contract_faults requires the test-only contract-faults build feature"
620            );
621            return Err(invalid(
622                "contract_faults",
623                "requires the test-only contract-faults build feature",
624            ));
625        }
626        if let Some(fault) = &self.fault {
627            if !self.contract_faults {
628                return Err(invalid("fault", "requires contract_faults = true"));
629            }
630            let valid = fault.split_once(':').is_some_and(|(verb, point)| {
631                matches!(verb, "git.push" | "git.pr_merge")
632                    && matches!(
633                        point,
634                        "reply-lost-after-effect" | "audit-fails-after-effect"
635                    )
636            });
637            if !valid {
638                return Err(invalid("fault", "unsupported contract fault selector"));
639            }
640        }
641        for (path, repository) in &self.repositories {
642            let key = format!("repositories.{path}.merge_refusals");
643            let mut seen: Vec<&str> = Vec::new();
644            for entry in &repository.merge_refusals {
645                if !GitWriteRepositoryConfig::MERGE_REFUSALS.contains(&entry.as_str()) {
646                    return Err(invalid(&key, "entries must be opener or last_pusher"));
647                }
648                if seen.contains(&entry.as_str()) {
649                    return Err(invalid(&key, "entries must not repeat"));
650                }
651                seen.push(entry);
652            }
653        }
654        // The default keychain program is Unix-only. Legacy configurations with
655        // no actor mappings cannot invoke it, so they remain loadable elsewhere.
656        if !cfg!(unix)
657            && self.actors.is_empty()
658            && self.credential_resolver == default_git_credential_resolver()
659        {
660            return Ok(());
661        }
662        let argv = &self.credential_resolver;
663        let Some(program) = argv.first() else {
664            return Err(invalid("credential_resolver", "argv must not be empty"));
665        };
666        let program_path = Path::new(program);
667        if !program_path.is_absolute() {
668            return Err(invalid(
669                "credential_resolver",
670                "argv[0] must be an absolute path",
671            ));
672        }
673        let program_name = program_path
674            .file_name()
675            .and_then(|name| name.to_str())
676            .unwrap_or_default()
677            .to_ascii_lowercase();
678        if matches!(
679            program_name.trim_end_matches(".exe"),
680            "sh" | "bash"
681                | "dash"
682                | "zsh"
683                | "ksh"
684                | "fish"
685                | "csh"
686                | "tcsh"
687                | "cmd"
688                | "powershell"
689                | "pwsh"
690                | "env"
691        ) {
692            return Err(invalid(
693                "credential_resolver",
694                "shell or env launcher is not allowed",
695            ));
696        }
697        if argv.iter().any(|arg| arg.chars().any(char::is_control)) {
698            return Err(invalid(
699                "credential_resolver",
700                "argv must not contain control characters",
701            ));
702        }
703        if program.contains(['{', '}'])
704            || argv[1..]
705                .iter()
706                .any(|arg| arg != "{ref}" && arg.contains(['{', '}']))
707        {
708            return Err(invalid(
709                "credential_resolver",
710                "{ref} must be a complete argument and is the only allowed template",
711            ));
712        }
713        if !argv[1..].iter().any(|arg| arg == "{ref}") {
714            return Err(invalid(
715                "credential_resolver",
716                "argv must contain a {ref} argument",
717            ));
718        }
719        for (actor, identity) in &self.actors {
720            if actor.trim().is_empty() || actor.chars().any(char::is_control) {
721                return Err(invalid(
722                    "actors",
723                    "actor labels must be nonempty and contain no control characters",
724                ));
725            }
726            for (field, value) in [
727                ("name", &identity.name),
728                ("email", &identity.email),
729                ("credential_ref", &identity.credential_ref),
730                ("platform_identity", &identity.platform_identity),
731            ] {
732                if value.trim().is_empty() || value.chars().any(char::is_control) {
733                    return Err(invalid(
734                        &format!("actors.{actor}.{field}"),
735                        "must be nonempty and contain no control characters",
736                    ));
737                }
738            }
739            if identity.name.contains(['<', '>']) || identity.email.contains(['<', '>']) {
740                return Err(invalid(
741                    &format!("actors.{actor}"),
742                    "name and email must not contain Git identity delimiters",
743                ));
744            }
745        }
746        Ok(())
747    }
748}
749
750// ---- exec sandbox (ADR-181) ----
751
752/// `[exec.limits]`: per-run resource limits applied to the sandboxed child
753/// and inherited by its descendants (`setrlimit` before exec).
754#[derive(Debug, Clone, Deserialize, Default)]
755pub struct ExecLimitsConfig {
756    #[serde(default)]
757    pub cpu_seconds: Option<u64>,
758    #[serde(default)]
759    pub address_space: Option<u64>,
760    #[serde(default)]
761    pub file_size: Option<u64>,
762    #[serde(default)]
763    pub nproc: Option<u64>,
764}
765
766/// `[exec]` section (ADR-181): where runs materialize, what they may read,
767/// which caller environment keys pass through, which executable paths the
768/// `never` list matches, and the output caps, wall-clock defaults and resource
769/// limits. `never` matches paths, not a program's capabilities (ADR-181 A9).
770///
771/// ```toml
772/// [exec]
773/// root = "/var/lib/khive/exec"
774/// read_roots = ["/opt/toolchains/python3.11"]
775/// env = ["SOURCE_DATE_EPOCH"]
776/// # The never list matches resolved executable paths, not renamed copies.
777/// never = ["/usr/bin/curl"]
778/// max_output_bytes = 1048576
779/// timeout_default_s = 30
780/// timeout_max_s = 600
781/// keep = false
782///
783/// [exec.limits]
784/// cpu_seconds = 60
785/// file_size = 104857600
786/// ```
787#[derive(Debug, Clone, Deserialize, Default)]
788pub struct ExecSectionConfig {
789    #[serde(default)]
790    pub root: Option<String>,
791    #[serde(default)]
792    pub read_roots: Vec<String>,
793    #[serde(default)]
794    pub env: Vec<String>,
795    #[serde(default)]
796    pub never: Vec<String>,
797    #[serde(default)]
798    pub max_output_bytes: Option<u64>,
799    #[serde(default)]
800    pub timeout_default_s: Option<f64>,
801    #[serde(default)]
802    pub timeout_max_s: Option<f64>,
803    #[serde(default)]
804    pub keep: bool,
805    #[serde(default)]
806    pub limits: ExecLimitsConfig,
807}
808
809// ---- web fetch/search policy (ADR-175 Amendment 1, carried into ADR-191 D3) ----
810
811/// One `[[web.allowlist]]` entry: an exclusive host the operator has opted
812/// into reachability for `web.fetch`/`web.search`. Presence of ANY entry
813/// makes the allowlist exclusive (ADR-175 A1.2.3); absence leaves the public
814/// internet reachable subject to the other egress rules. Matched by exact,
815/// normalized (lowercase, trailing-dot-stripped) host equality only — no
816/// suffix wildcarding, unlike `[[web.credentials]].hosts` (A1.2.6).
817#[derive(Debug, Clone, Deserialize, Serialize)]
818#[serde(deny_unknown_fields)]
819pub struct WebAllowlistEntry {
820    pub host: String,
821}
822
823/// One `[[web.credentials]]` entry: a named secret, read from the process
824/// environment at request time (never accepted as a verb argument), bound to
825/// the set of hosts it may be presented to. Each `hosts` entry is either an
826/// exact IP-literal address (matched exactly, never as a suffix) or a
827/// hostname suffix (`example.com` matches `example.com` and any
828/// `*.example.com` at a DNS label boundary) — ADR-175 A1.2.6.
829#[derive(Debug, Clone, Deserialize, Serialize)]
830#[serde(deny_unknown_fields)]
831pub struct WebCredentialConfig {
832    /// Name the caller passes as `web.fetch`'s `credential` argument.
833    pub name: String,
834    /// Process environment variable holding the secret value.
835    pub env_var: String,
836    /// Non-empty set of hosts (exact IP literals or hostname suffixes) this
837    /// credential may be presented to.
838    pub hosts: Vec<String>,
839}
840
841/// One canned result inside a `kind = "fixture"` `[[web.search_providers]]`
842/// entry — deterministic, non-networked search results (demos, offline
843/// corpora, and the fixture arm of `web.search`'s own test suite).
844#[derive(Debug, Clone, Deserialize, Serialize)]
845#[serde(deny_unknown_fields)]
846pub struct WebFixtureResult {
847    pub title: String,
848    pub url: String,
849    pub snippet: String,
850}
851
852/// One `[[web.search_providers]]` entry (ADR-175 A1.3). The provider is
853/// operator configuration; `web.search`'s `provider` argument only selects
854/// among entries declared here by `name`. Closed, tagged on `kind`.
855#[derive(Debug, Clone, Deserialize, Serialize)]
856#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
857pub enum WebSearchProviderConfig {
858    /// Deterministic canned results — no outbound request.
859    Fixture {
860        name: String,
861        #[serde(default)]
862        default: bool,
863        results: Vec<WebFixtureResult>,
864    },
865    /// A real HTTP GET search backend. `url_template` must contain the
866    /// literal substring `{query}`, replaced with the percent-encoded query
867    /// at request time; `{limit}` is replaced with the effective limit when
868    /// present. The response body is JSON: an array of `{title, url,
869    /// snippet}` objects. `api_key_env`, when set, is a process environment
870    /// variable sent as `Authorization: Bearer <value>`.
871    Http {
872        name: String,
873        #[serde(default)]
874        default: bool,
875        url_template: String,
876        #[serde(default)]
877        api_key_env: Option<String>,
878        /// Hosts (exact IP literals or hostname suffixes) `api_key_env`'s
879        /// value may be presented to, modeled on
880        /// `[[web.credentials]].hosts`. Required non-empty whenever
881        /// `api_key_env` is set (`validate` enforces this) — an unscoped key
882        /// would ride along to whatever host `url_template` resolves to,
883        /// which defeats the point of scoping it at all.
884        #[serde(default)]
885        hosts: Vec<String>,
886    },
887}
888
889impl WebSearchProviderConfig {
890    pub fn name(&self) -> &str {
891        match self {
892            WebSearchProviderConfig::Fixture { name, .. } => name,
893            WebSearchProviderConfig::Http { name, .. } => name,
894        }
895    }
896
897    pub fn is_default(&self) -> bool {
898        match self {
899            WebSearchProviderConfig::Fixture { default, .. } => *default,
900            WebSearchProviderConfig::Http { default, .. } => *default,
901        }
902    }
903}
904
905/// `[web]` section (ADR-175 Amendment 1, ADR-191 D3): operator policy for
906/// `web.fetch` and `web.search` — ceilings, the address allowlist, credential
907/// host-set bindings, and configured search providers.
908///
909/// No generic per-pack settings map exists in this file today (`PackConfig`
910/// carries only `backend`/`no_embed`, both storage-routing concerns) so this
911/// follows the established precedent for a pack needing rich operator policy:
912/// a dedicated top-level section threaded through `RuntimeConfig`, the same
913/// shape as `[exec]` and `[git_write]`.
914///
915/// ```toml
916/// [web]
917/// timeout_default_s = 30
918/// timeout_max_s = 120
919/// max_bytes_default = 5242880
920/// max_bytes_max = 52428800
921/// search_limit_default = 10
922/// search_limit_max = 50
923///
924/// [[web.allowlist]]
925/// host = "example.com"
926///
927/// [[web.credentials]]
928/// name = "example-token"
929/// env_var = "EXAMPLE_API_TOKEN"
930/// hosts = ["example.com"]
931///
932/// [[web.search_providers]]
933/// kind = "fixture"
934/// name = "demo"
935/// default = true
936/// results = [{ title = "Example", url = "https://example.com", snippet = "..." }]
937/// ```
938#[derive(Debug, Clone, Deserialize, Serialize, Default)]
939#[serde(deny_unknown_fields)]
940pub struct WebSectionConfig {
941    #[serde(default)]
942    pub timeout_default_s: Option<u64>,
943    #[serde(default)]
944    pub timeout_max_s: Option<u64>,
945    #[serde(default)]
946    pub max_bytes_default: Option<u64>,
947    #[serde(default)]
948    pub max_bytes_max: Option<u64>,
949    #[serde(default)]
950    pub search_limit_default: Option<u32>,
951    #[serde(default)]
952    pub search_limit_max: Option<u32>,
953    #[serde(default)]
954    pub allowlist: Vec<WebAllowlistEntry>,
955    #[serde(default)]
956    pub credentials: Vec<WebCredentialConfig>,
957    #[serde(default)]
958    pub search_providers: Vec<WebSearchProviderConfig>,
959    /// Directories `web.ingest`'s disk mode may read from, modeled on
960    /// `[exec] read_roots`. Absent or empty fails closed: disk ingest is
961    /// refused entirely until the operator names at least one root.
962    #[serde(default)]
963    pub read_roots: Vec<String>,
964}
965
966/// Effective operator bounds shared by file validation and programmatic web dispatch.
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub struct WebCeilings {
969    pub timeout_default_s: u64,
970    pub timeout_max_s: u64,
971    pub max_bytes_default: u64,
972    pub max_bytes_max: u64,
973    pub search_limit_default: u32,
974    pub search_limit_max: u32,
975}
976
977impl Default for WebCeilings {
978    fn default() -> Self {
979        Self {
980            timeout_default_s: 30,
981            timeout_max_s: 120,
982            max_bytes_default: 5 * 1024 * 1024,
983            max_bytes_max: 50 * 1024 * 1024,
984            search_limit_default: 10,
985            search_limit_max: 50,
986        }
987    }
988}
989
990impl WebSectionConfig {
991    pub fn resolved_ceilings(&self) -> Result<WebCeilings, ConfigError> {
992        let defaults = WebCeilings::default();
993        let bounds = WebCeilings {
994            timeout_default_s: self.timeout_default_s.unwrap_or(defaults.timeout_default_s),
995            timeout_max_s: self.timeout_max_s.unwrap_or(defaults.timeout_max_s),
996            max_bytes_default: self.max_bytes_default.unwrap_or(defaults.max_bytes_default),
997            max_bytes_max: self.max_bytes_max.unwrap_or(defaults.max_bytes_max),
998            search_limit_default: self
999                .search_limit_default
1000                .unwrap_or(defaults.search_limit_default),
1001            search_limit_max: self.search_limit_max.unwrap_or(defaults.search_limit_max),
1002        };
1003        for (key, default, maximum, maximum_key) in [
1004            (
1005                "timeout_default_s",
1006                bounds.timeout_default_s,
1007                bounds.timeout_max_s,
1008                "timeout_max_s",
1009            ),
1010            (
1011                "max_bytes_default",
1012                bounds.max_bytes_default,
1013                bounds.max_bytes_max,
1014                "max_bytes_max",
1015            ),
1016            (
1017                "search_limit_default",
1018                u64::from(bounds.search_limit_default),
1019                u64::from(bounds.search_limit_max),
1020                "search_limit_max",
1021            ),
1022        ] {
1023            if default == 0 || default > maximum {
1024                return Err(ConfigError::InvalidWebConfig {
1025                    key: key.into(),
1026                    reason: format!(
1027                        "resolved default must be positive and not exceed {maximum_key}={maximum}"
1028                    ),
1029                });
1030            }
1031        }
1032        if std::time::Instant::now()
1033            .checked_add(std::time::Duration::from_secs(bounds.timeout_max_s))
1034            .is_none()
1035        {
1036            return Err(ConfigError::InvalidWebConfig {
1037                key: "timeout_max_s".into(),
1038                reason: "cannot be represented as a request deadline".into(),
1039            });
1040        }
1041        Ok(bounds)
1042    }
1043
1044    pub fn validate(&self) -> Result<(), ConfigError> {
1045        self.resolved_ceilings()?;
1046        let invalid = |key: &str, reason: &str| ConfigError::InvalidWebConfig {
1047            key: key.to_string(),
1048            reason: reason.to_string(),
1049        };
1050        let mut seen_hosts = std::collections::HashSet::new();
1051        for entry in &self.allowlist {
1052            let normalized = entry.host.trim().trim_end_matches('.').to_ascii_lowercase();
1053            if normalized.is_empty() {
1054                return Err(invalid("allowlist.host", "must not be empty"));
1055            }
1056            if !seen_hosts.insert(normalized) {
1057                return Err(invalid("allowlist.host", "duplicate host entry"));
1058            }
1059        }
1060        let mut seen_credentials = std::collections::HashSet::new();
1061        for credential in &self.credentials {
1062            if credential.name.trim().is_empty() {
1063                return Err(invalid("credentials.name", "must not be empty"));
1064            }
1065            if !seen_credentials.insert(credential.name.clone()) {
1066                return Err(invalid("credentials.name", "duplicate credential name"));
1067            }
1068            if credential.env_var.trim().is_empty() {
1069                return Err(invalid("credentials.env_var", "must not be empty"));
1070            }
1071            if credential.hosts.is_empty() {
1072                return Err(invalid(
1073                    "credentials.hosts",
1074                    "must name at least one host or suffix",
1075                ));
1076            }
1077        }
1078        let mut seen_providers = std::collections::HashSet::new();
1079        let mut default_count = 0;
1080        for provider in &self.search_providers {
1081            let name = provider.name();
1082            if name.trim().is_empty() {
1083                return Err(invalid("search_providers.name", "must not be empty"));
1084            }
1085            if !seen_providers.insert(name.to_string()) {
1086                return Err(invalid("search_providers.name", "duplicate provider name"));
1087            }
1088            if provider.is_default() {
1089                default_count += 1;
1090            }
1091            if let WebSearchProviderConfig::Http {
1092                url_template,
1093                api_key_env,
1094                hosts,
1095                ..
1096            } = provider
1097            {
1098                if !url_template.contains("{query}") {
1099                    return Err(invalid(
1100                        "search_providers.url_template",
1101                        "must contain the literal substring {query}",
1102                    ));
1103                }
1104                if api_key_env.is_some() && hosts.is_empty() {
1105                    return Err(invalid(
1106                        "search_providers.hosts",
1107                        "an api_key_env-bearing provider must name at least one host or suffix",
1108                    ));
1109                }
1110            }
1111        }
1112        if default_count > 1 {
1113            return Err(invalid(
1114                "search_providers",
1115                "at most one provider may set default = true",
1116            ));
1117        }
1118        Ok(())
1119    }
1120}
1121
1122/// Top-level khive configuration loaded from `khive.toml` or `config.toml`.
1123///
1124/// Sections consumed today:
1125/// - `[[engines]]`: embedding engine declarations
1126/// - `[actor]`: default namespace / identity (OSS actor model)
1127/// - `[gate]`: built-in caller enrollment
1128/// - `[runtime]`: runtime knobs (pack selection, brain profile, output format)
1129/// - `[brain]`: actor read policy
1130/// - `[telemetry]`: stream and channel carrier policy
1131/// - `[[backends]]`: storage backend declarations (ADR-028)
1132/// - `[packs.<name>]`: per-pack backend assignments (ADR-028)
1133/// - `[display]`: rendering timezone (ADR-169)
1134///
1135/// Unknown top-level keys are silently ignored by serde for forward
1136/// compatibility. The `[actor]`, `[gate]`, `[brain]`, and `[telemetry]` tables are closed
1137/// with `deny_unknown_fields` so a misspelled policy key always fails startup.
1138#[derive(Debug, Clone, Deserialize, Default)]
1139pub struct KhiveConfig {
1140    #[serde(default)]
1141    pub mounts: Vec<crate::mount_config::MountConfig>,
1142
1143    /// Typed only so a top-level `db` key can be rejected loudly by
1144    /// [`KhiveConfig::validate`] instead of being silently ignored as an
1145    /// unknown key. Not a supported config-file storage selector: single-file
1146    /// database selection is `--db`/`KHIVE_DB`, and storage topology is
1147    /// `[[backends]].path`.
1148    #[serde(default)]
1149    pub db: Option<String>,
1150
1151    /// Embedding engine declarations.
1152    #[serde(default)]
1153    pub engines: Vec<EngineConfig>,
1154
1155    /// Default actor identity for this khive instance.
1156    ///
1157    /// When present, `actor.id` feeds configuration identity and gate/attribution
1158    /// policy input.  A non-`'local'` `actor.id` is folded into the default READ
1159    /// visible-set at config load (ADR-007 Rev 4 Rule 3b) — it widens what default
1160    /// multi-record reads return, but never routes writes or sets `default_namespace`.
1161    /// Cloud model derives actor identity from an authenticated token.
1162    #[serde(default)]
1163    pub actor: ActorConfig,
1164
1165    /// Optional caller-enrollment policy. A present, even empty, table is an
1166    /// explicit fail-closed policy; an absent table preserves the runtime's
1167    /// existing gate.
1168    #[serde(default)]
1169    pub gate: Option<GateSectionConfig>,
1170
1171    /// Runtime knobs: namespace overrides, brain profile, etc.
1172    #[serde(default)]
1173    pub runtime: RuntimeSectionConfig,
1174
1175    /// Named storage backends (ADR-028).
1176    ///
1177    /// When absent or empty, a single implicit `main` backend is used and all
1178    /// packs share it — identical to pre-ADR-028 behavior.
1179    #[serde(default)]
1180    pub backends: Vec<BackendConfig>,
1181
1182    /// Per-pack backend assignments (ADR-028).
1183    ///
1184    /// Maps pack name to backend name. Packs absent from this map fall back to
1185    /// the `main` backend. Validated at load time: every referenced backend name
1186    /// must appear in `backends`.
1187    #[serde(default)]
1188    pub packs: std::collections::HashMap<String, PackConfig>,
1189
1190    /// Actor read policy. An absent or empty list grants no fleet-wide reads.
1191    #[serde(default)]
1192    pub brain: BrainSectionConfig,
1193
1194    /// Git-write policy allowlist (ADR-108 Amendment). Absent or empty
1195    /// `allowed` fails closed — `khive-pack-git`'s write verbs are
1196    /// unavailable until this section is populated.
1197    #[serde(default)]
1198    pub git_write: GitWriteSectionConfig,
1199
1200    /// Storage-layer config not covered by `[[backends]]` (ADR-111
1201    /// Amendment 2: `[storage.blob]`'s `fs`/`s3` selector).
1202    #[serde(default)]
1203    pub storage: StorageSectionConfig,
1204
1205    /// Exec sandbox section (ADR-181). Absent means no runs: the exec pack
1206    /// refuses every `exec.run` until `[exec] read_roots` names a toolchain.
1207    #[serde(default)]
1208    pub exec: ExecSectionConfig,
1209
1210    /// Stream and channel carrier policy. Unclassified kinds default to ephemeral.
1211    #[serde(default)]
1212    pub telemetry: crate::telemetry_config::TelemetryConfig,
1213
1214    /// Rendering timezone configuration (ADR-169). Absent `timezone` resolves
1215    /// to the host's local zone at [`RuntimeConfig`](crate::RuntimeConfig)
1216    /// construction time.
1217    #[serde(default)]
1218    pub display: DisplaySectionConfig,
1219
1220    /// `web.fetch`/`web.search` operator policy (ADR-175 Amendment 1).
1221    /// Absent is the fail-closed default for search (no provider configured)
1222    /// and the permissive-subject-to-address-rules default for fetch (no
1223    /// allowlist configured).
1224    #[serde(default)]
1225    pub web: WebSectionConfig,
1226}
1227
1228/// `[runtime]` section in `khive.toml`.
1229///
1230/// Carries runtime knobs resolved during process construction. Most mirror a
1231/// CLI flag / environment tier; field documentation calls out exceptions.
1232/// All fields are optional and preserve their already-resolved base value when
1233/// absent.
1234#[derive(Debug, Clone, Deserialize, Default)]
1235pub struct RuntimeSectionConfig {
1236    /// Packs to load when neither `--pack` nor `KHIVE_PACKS` selects them.
1237    /// An absent or empty list preserves the built-in production default.
1238    #[serde(default)]
1239    pub packs: Option<Vec<String>>,
1240
1241    /// Brain profile ID to use for `memory.feedback` / `knowledge.feedback`
1242    /// and recall-time score boosting (ADR-035 §Brain profile configuration).
1243    ///
1244    /// Mirrors `--brain-profile` / `KHIVE_BRAIN_PROFILE`. When absent, the
1245    /// namespace-bound profile (via `brain.resolve`) is tried, then the
1246    /// global tuning prior is used as the final fallback.
1247    #[serde(default)]
1248    pub brain_profile: Option<String>,
1249
1250    /// Default output serialization format (ADR-078).
1251    ///
1252    /// Mirrors `--output-format` / `KHIVE_OUTPUT_FORMAT`. Precedence (highest to lowest):
1253    /// per-request `format` field → `KHIVE_OUTPUT_FORMAT` → this field → builtin `json`.
1254    ///
1255    /// Accepted values: `"json"` (default), `"auto"`, `"table"`.
1256    #[serde(default)]
1257    pub default_output_format: Option<OutputFormat>,
1258
1259    /// Aggregate process-local admission budget for digest-verified blob
1260    /// hydration (ADR-160 D3), in raw bytes.
1261    ///
1262    /// This knob has no environment-variable counterpart. When absent, the
1263    /// resolved runtime keeps its built-in 256 MiB default (or a value supplied
1264    /// directly through `RuntimeConfig`).
1265    #[serde(default)]
1266    pub blob_hydration_bytes: Option<u64>,
1267}
1268
1269/// `[display]` section in `khive.toml` — the timezone khive anchors date-only
1270/// input to (ADR-169 Implementation step 1).
1271///
1272/// ```toml
1273/// [display]
1274/// timezone = "America/New_York"
1275/// ```
1276#[derive(Debug, Clone, Deserialize, Default)]
1277pub struct DisplaySectionConfig {
1278    /// IANA zone name (e.g. `"America/New_York"`, `"Asia/Tokyo"`, `"UTC"`).
1279    /// Validated at load time against `chrono_tz::Tz`'s zone table — an
1280    /// unrecognized name is a startup error, not a silent fallback. Absent →
1281    /// the host's local zone, resolved once via `iana-time-zone` and falling
1282    /// back to UTC when the host zone cannot be determined.
1283    #[serde(default)]
1284    pub timezone: Option<String>,
1285}
1286
1287impl KhiveConfig {
1288    /// Load and validate a `KhiveConfig` from an explicit path.
1289    ///
1290    /// Search order:
1291    /// 1. `path` argument (explicit override — e.g. from `--config` / `KHIVE_CONFIG`)
1292    /// 2. `./.khive/config.toml` (project-local config, relative to the MCP server cwd)
1293    ///
1294    /// The project-local default collocates config with the `khive-test.db` that already
1295    /// lives under `.khive/` in each project directory. `~/.khive/config.toml` is searched
1296    /// by [`KhiveConfig::load_with_home_fallback`] when the project-local file is absent.
1297    ///
1298    /// If the resolved file does **not exist**, returns `Ok(None)`.
1299    /// A missing config is not an error — callers fall back to the env-var path.
1300    ///
1301    /// If the file exists but cannot be parsed, returns a `ConfigError`.
1302    /// After parsing, `validate()` runs and any logical errors are returned.
1303    pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
1304        let resolved = match path {
1305            Some(p) => p.to_path_buf(),
1306            None => PathBuf::from(".khive/config.toml"),
1307        };
1308
1309        if !resolved.exists() {
1310            return Ok(None);
1311        }
1312
1313        // Diagnostics name the canonical path so an error is actionable from
1314        // any cwd; resolution keeps using `resolved` as given.
1315        let diagnostic_path = std::fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1316        let raw = std::fs::read_to_string(&resolved)
1317            .map_err(|source| ConfigError::from(source).in_file(&diagnostic_path))?;
1318        let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
1319            path: diagnostic_path.clone(),
1320            source,
1321        })?;
1322        cfg.validate()
1323            .map_err(|error| error.in_file(&diagnostic_path))?;
1324        Ok(Some(cfg))
1325    }
1326
1327    /// Load config with the full resolution order:
1328    ///
1329    /// 1. Explicit `path` (from `--config` / `KHIVE_CONFIG`)
1330    /// 2. `./khive.toml` (project-local, project root)
1331    /// 3. `<db-dir>/config.toml` (project-local, anchored to the resolved database's
1332    ///    own directory — see `project_config_anchor_dir`)
1333    /// 4. `~/.khive/config.toml` (user-global)
1334    ///
1335    /// Returns the first file found, or `Ok(None)` when none exist.
1336    /// Parse errors are propagated immediately — a malformed config is always
1337    /// an error regardless of which tier it came from.
1338    ///
1339    /// The explicit tier (1) is stricter than the discovery tiers: a `path`
1340    /// that names a file which does not exist returns
1341    /// [`ConfigError::ExplicitConfigMissing`] instead of falling through to
1342    /// tiers 2-4 — an operator-selected config that is missing is the same
1343    /// class of mistake as one that is malformed, and silently discovering a
1344    /// different file would boot against a config the operator did not select
1345    /// (ADR-035).
1346    ///
1347    /// `db_path` should be the same database path the caller is about to open
1348    /// (or has already resolved). Passing it makes tier 3 resolve identically
1349    /// for any two processes that target the same database, regardless of
1350    /// their process working directory — this is what lets a thin client and
1351    /// a warm daemon serving the same database agree on one config file. Pass
1352    /// `None` when no database path is known yet; tier 3 then falls back to
1353    /// the process cwd, matching the pre-existing behavior.
1354    pub fn load_with_home_fallback(
1355        path: Option<&Path>,
1356        db_path: Option<&Path>,
1357    ) -> Result<Option<Self>, ConfigError> {
1358        Ok(Self::load_with_home_fallback_and_source(path, db_path)?.map(|(config, _)| config))
1359    }
1360
1361    /// Load config with the full resolution order and retain the exact file
1362    /// that supplied it.
1363    ///
1364    /// This is the diagnostic-preserving form of
1365    /// [`KhiveConfig::load_with_home_fallback`]. Runtime callers that need to
1366    /// tell an operator which selected file must be edited should use this
1367    /// method instead of reconstructing the discovery order independently.
1368    pub fn load_with_home_fallback_and_source(
1369        path: Option<&Path>,
1370        db_path: Option<&Path>,
1371    ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1372        // Tier 1: explicit path (highest priority). An explicit selection
1373        // naming a MISSING file fails loud here — once, at the loader, for
1374        // every entry point — instead of silently falling through to the
1375        // discovery tiers: a mistyped path would otherwise boot against a
1376        // config the operator did not select (ADR-035: an entry point must
1377        // not document an explicit tier while silently falling back to
1378        // discovery). Tiers 2-4 keep their tolerant contract.
1379        if let Some(p) = path {
1380            if !p.exists() {
1381                return Err(ConfigError::ExplicitConfigMissing {
1382                    path: p.to_path_buf(),
1383                });
1384            }
1385            return Ok(Self::load(Some(p))?.map(|config| (config, Self::diagnostic_config_path(p))));
1386        }
1387
1388        // Tiers 2-4: search project root, db-anchored hidden dir, user-global.
1389        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1390        let home_root = std::env::var_os("HOME").map(PathBuf::from);
1391        Self::load_with_roots_and_source(&project_root, home_root.as_deref(), db_path)
1392    }
1393
1394    /// Testable inner search: tiers 2-4, given explicit roots instead of
1395    /// reading `cwd` and `HOME` from process state.
1396    ///
1397    /// - Tier 2: `<project_root>/khive.toml` (still cwd-anchored — unchanged)
1398    /// - Tier 3: `<db_dir>/config.toml`, anchored to `db_path` rather than
1399    ///   `project_root` (see `project_config_anchor_dir`); falls back to
1400    ///   `<project_root>/.khive/config.toml` when `db_path` is `None`
1401    /// - Tier 4: `<home_root>/.khive/config.toml` (skipped when `None`)
1402    #[cfg(test)]
1403    pub(crate) fn load_with_roots(
1404        project_root: &Path,
1405        home_root: Option<&Path>,
1406        db_path: Option<&Path>,
1407    ) -> Result<Option<Self>, ConfigError> {
1408        Ok(
1409            Self::load_with_roots_and_source(project_root, home_root, db_path)?
1410                .map(|(config, _)| config),
1411        )
1412    }
1413
1414    fn load_with_roots_and_source(
1415        project_root: &Path,
1416        home_root: Option<&Path>,
1417        db_path: Option<&Path>,
1418    ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1419        // Tier 2: project root khive.toml.
1420        let tier2 = project_root.join("khive.toml");
1421        if tier2.exists() {
1422            return Ok(Self::load(Some(&tier2))?
1423                .map(|config| (config, Self::diagnostic_config_path(&tier2))));
1424        }
1425
1426        // Tier 3: project-local hidden dir, anchored to the resolved database's
1427        // own directory instead of the process cwd.
1428        let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
1429        if tier3.exists() {
1430            return Ok(Self::load(Some(&tier3))?
1431                .map(|config| (config, Self::diagnostic_config_path(&tier3))));
1432        }
1433
1434        // Tier 4: user-global ~/.khive/config.toml.
1435        if let Some(home) = home_root {
1436            let tier4 = home.join(".khive/config.toml");
1437            if tier4.exists() {
1438                return Ok(Self::load(Some(&tier4))?
1439                    .map(|config| (config, Self::diagnostic_config_path(&tier4))));
1440            }
1441        }
1442
1443        Ok(None)
1444    }
1445
1446    fn diagnostic_config_path(path: &Path) -> PathBuf {
1447        std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1448    }
1449
1450    /// Resolve the directory searched for the tier-3 project-local config file.
1451    ///
1452    /// Anchored to the directory containing the resolved database file, not the
1453    /// process cwd: two processes at different working directories that open the
1454    /// same database agree on this directory, which is what keeps their
1455    /// `config_id` fingerprints in sync (a client and a warm daemon serving the
1456    /// same database must resolve identical config so the daemon accepts the
1457    /// client's forwarded requests instead of rejecting them on a config
1458    /// mismatch).
1459    ///
1460    /// `db_path` is canonicalized first so symlinks/relative components collapse
1461    /// to the same absolute directory regardless of caller cwd. The database file
1462    /// may not exist yet (first run before anything has been written) — in that
1463    /// case canonicalization fails and the path is absolutized against
1464    /// `project_root` instead (or used as-is if already absolute); this must
1465    /// never panic, it is the expected cold-start case.
1466    ///
1467    /// If `db_dir` (the resolved database's parent directory) is itself named
1468    /// `.khive`, the config lives directly inside it (`<db_dir>/config.toml`) —
1469    /// this is the common case where the database is `<root>/.khive/khive.db`.
1470    /// Otherwise the config lives in a `.khive` subdirectory of `db_dir`.
1471    ///
1472    /// `db_path == None` (e.g. an in-memory database, or no database path known
1473    /// yet) falls back to `<project_root>/.khive`, preserving the pre-existing
1474    /// cwd-anchored behavior for callers with no database to anchor on.
1475    fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
1476        let Some(db_path) = db_path else {
1477            return project_root.join(".khive");
1478        };
1479
1480        let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
1481            if db_path.is_absolute() {
1482                db_path.to_path_buf()
1483            } else {
1484                project_root.join(db_path)
1485            }
1486        });
1487
1488        let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
1489
1490        if db_dir.file_name().is_some_and(|name| name == ".khive") {
1491            db_dir
1492        } else {
1493            db_dir.join(".khive")
1494        }
1495    }
1496
1497    /// Validate the parsed config for logical consistency.
1498    ///
1499    /// Checks:
1500    /// - Exactly one engine has `default = true` (when the list is non-empty).
1501    /// - Engine names are unique.
1502    /// - Every engine model is recognized by the runtime's alias parser.
1503    /// - `fusion_weight`, when present, is `> 0`.
1504    pub fn validate(&self) -> Result<(), ConfigError> {
1505        crate::mount_config::validate_mounts(&self.mounts)?;
1506        self.git_write.validate_dev_loop()?;
1507        self.telemetry.validate()?;
1508        self.web.validate()?;
1509
1510        // Reject a top-level `db` key loudly instead of letting serde's
1511        // forward-compatible unknown-key tolerance silently swallow it: a
1512        // config author expecting `db=` to select the database would
1513        // otherwise get silent divergence from `--db`/`KHIVE_DB`.
1514        if let Some(value) = self.db.as_deref() {
1515            if !value.is_empty() {
1516                return Err(ConfigError::UnsupportedTopLevelDb {
1517                    value: value.to_string(),
1518                });
1519            }
1520        }
1521
1522        // ADR-181 resource limits: macOS returns EINVAL for RLIMIT_AS and
1523        // RLIMIT_DATA, and RLIMIT_NPROC counts every process of the uid, so
1524        // neither can bound one run. Refuse loudly instead of pretending.
1525        if cfg!(target_os = "macos") {
1526            if self.exec.limits.address_space.is_some() {
1527                return Err(ConfigError::InvalidExecConfig {
1528                    key: "limits.address_space".to_string(),
1529                    reason: "unsupported_on_platform: macOS does not enforce an address-space rlimit per process".to_string(),
1530                });
1531            }
1532            if self.exec.limits.nproc.is_some() {
1533                return Err(ConfigError::InvalidExecConfig {
1534                    key: "limits.nproc".to_string(),
1535                    reason: "unsupported_on_platform: RLIMIT_NPROC counts every process of the uid, not one run".to_string(),
1536                });
1537            }
1538        }
1539        if let (Some(d), Some(m)) = (self.exec.timeout_default_s, self.exec.timeout_max_s) {
1540            if d > m {
1541                return Err(ConfigError::InvalidExecConfig {
1542                    key: "timeout_default_s".to_string(),
1543                    reason: format!("default {d} exceeds timeout_max_s {m}"),
1544                });
1545            }
1546        }
1547
1548        if let Some(value) = self.runtime.blob_hydration_bytes {
1549            let min = khive_storage::MAX_BLOB_WHOLE_BYTES;
1550            let max = tokio::sync::Semaphore::MAX_PERMITS as u64;
1551            if value < min || value > max {
1552                return Err(ConfigError::InvalidBlobHydrationBytes { value, min, max });
1553            }
1554        }
1555
1556        // Validate actor.id when present — an invalid namespace is a startup error,
1557        // not a silent fallback.
1558        if let Some(id) = self.actor.id.as_deref() {
1559            if id.is_empty() {
1560                return Err(ConfigError::InvalidActorId {
1561                    id: id.to_string(),
1562                    reason: "actor.id must not be empty; remove the key or provide a value"
1563                        .to_string(),
1564                });
1565            }
1566            Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
1567                id: id.to_string(),
1568                reason: e.to_string(),
1569            })?;
1570        }
1571
1572        self.actor
1573            .mailbox_gate(std::sync::Arc::new(khive_gate::AllowAllGate))
1574            .map_err(|error| ConfigError::InvalidMailboxReaders {
1575                reason: error.to_string(),
1576            })?;
1577
1578        if let Some(ref vis) = self.actor.visible_namespaces {
1579            for ns_str in vis {
1580                if ns_str.is_empty() {
1581                    return Err(ConfigError::InvalidActorId {
1582                        id: ns_str.clone(),
1583                        reason: "visible_namespaces entries must not be empty".to_string(),
1584                    });
1585                }
1586                Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1587                    id: ns_str.clone(),
1588                    reason: format!("invalid visible namespace: {e}"),
1589                })?;
1590            }
1591        }
1592
1593        if let Some(gate) = &self.gate {
1594            khive_gate::CallerEnrollmentGate::validate_write_denials(&gate.deny_writes_for)
1595                .map_err(|error| ConfigError::InvalidWriteDenyPatterns {
1596                    reason: error.to_string(),
1597                })?;
1598            for id in &gate.granted_actors {
1599                if id.is_empty() {
1600                    return Err(ConfigError::InvalidGrantedActorId {
1601                        id: id.clone(),
1602                        reason: "actor ids must not be empty".to_string(),
1603                    });
1604                }
1605                Namespace::parse(id).map_err(|error| ConfigError::InvalidGrantedActorId {
1606                    id: id.clone(),
1607                    reason: error.to_string(),
1608                })?;
1609            }
1610        }
1611
1612        // Validate actor.allowed_outbound_namespaces (fail-closed at startup on malformed entry).
1613        for ns_str in &self.actor.allowed_outbound_namespaces {
1614            if ns_str.is_empty() {
1615                return Err(ConfigError::InvalidActorId {
1616                    id: ns_str.clone(),
1617                    reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
1618                });
1619            }
1620            Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1621                id: ns_str.clone(),
1622                reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
1623            })?;
1624        }
1625
1626        // Backend names must be unique.
1627        if !self.backends.is_empty() {
1628            let mut seen_backends = std::collections::HashSet::new();
1629            for backend in &self.backends {
1630                BackendId::parse(&backend.name).map_err(|error| {
1631                    ConfigError::InvalidBackendName {
1632                        name: backend.name.clone(),
1633                        reason: error.to_string(),
1634                    }
1635                })?;
1636                if backend
1637                    .served_kinds
1638                    .as_ref()
1639                    .is_some_and(BTreeSet::is_empty)
1640                {
1641                    return Err(ConfigError::EmptyBackendServedKinds {
1642                        name: backend.name.clone(),
1643                    });
1644                }
1645                if !seen_backends.insert(backend.name.clone()) {
1646                    return Err(ConfigError::DuplicateBackendName {
1647                        name: backend.name.clone(),
1648                    });
1649                }
1650
1651                // Reject fields that are parsed but not yet implemented: silently
1652                // accepting them would let misconfiguration slip past startup.
1653                if backend.cache_mb.is_some() {
1654                    return Err(ConfigError::UnsupportedBackendField {
1655                        name: backend.name.clone(),
1656                        field: "cache_mb",
1657                    });
1658                }
1659                if backend.journal_mode.is_some() {
1660                    return Err(ConfigError::UnsupportedBackendField {
1661                        name: backend.name.clone(),
1662                        field: "journal_mode",
1663                    });
1664                }
1665            }
1666        }
1667
1668        let defined: Vec<&str> = if self.backends.is_empty() {
1669            vec![BackendId::MAIN]
1670        } else {
1671            self.backends.iter().map(|b| b.name.as_str()).collect()
1672        };
1673        for (pack_name, pack_cfg) in &self.packs {
1674            if !defined.contains(&pack_cfg.backend.as_str()) {
1675                return Err(ConfigError::UnknownPackBackend {
1676                    pack: pack_name.clone(),
1677                    backend: pack_cfg.backend.clone(),
1678                    defined: defined.join(", "),
1679                });
1680            }
1681        }
1682
1683        if !self.backends.is_empty() {
1684            let missing: Vec<_> = [SubstrateKind::Note, SubstrateKind::Entity]
1685                .into_iter()
1686                .filter(|kind| {
1687                    !self.backends.iter().any(|backend| {
1688                        backend
1689                            .served_kinds
1690                            .as_ref()
1691                            .is_none_or(|served| served.contains(kind))
1692                    })
1693                })
1694                .collect();
1695            if !missing.is_empty() {
1696                return Err(ConfigError::MissingBackendSearchKinds {
1697                    kinds: missing,
1698                    defined: defined.join(", "),
1699                });
1700            }
1701        }
1702
1703        // Validate [display] timezone (ADR-169): an unrecognized IANA zone
1704        // name is a startup error, not a silent fallback to the host zone.
1705        if let Some(tz) = self.display.timezone.as_deref() {
1706            if tz.trim().is_empty() || tz.parse::<chrono_tz::Tz>().is_err() {
1707                return Err(ConfigError::InvalidDisplayTimezone {
1708                    timezone: tz.to_string(),
1709                });
1710            }
1711        }
1712
1713        // Validate [[git_write.allowed]] entries (ADR-108 Amendment): each
1714        // repo must be a non-empty absolute path, and each entry must carry
1715        // at least one branch pattern — an entry with an empty `branches`
1716        // list would silently allowlist a repo for no branch at all, which
1717        // reads as "configured" while behaving identically to "not
1718        // allowlisted"; reject it loudly instead of leaving that trap.
1719        for entry in &self.git_write.allowed {
1720            if entry.repo.trim().is_empty() {
1721                return Err(ConfigError::InvalidGitWriteEntry {
1722                    repo: entry.repo.clone(),
1723                    reason: "repo must not be empty".to_string(),
1724                });
1725            }
1726            if !Path::new(&entry.repo).is_absolute() {
1727                return Err(ConfigError::InvalidGitWriteEntry {
1728                    repo: entry.repo.clone(),
1729                    reason: "repo must be an absolute path".to_string(),
1730                });
1731            }
1732            if entry.branches.is_empty() {
1733                return Err(ConfigError::InvalidGitWriteEntry {
1734                    repo: entry.repo.clone(),
1735                    reason: "branches must not be empty".to_string(),
1736                });
1737            }
1738            if entry.branches.iter().any(|b| b.trim().is_empty()) {
1739                return Err(ConfigError::InvalidGitWriteEntry {
1740                    repo: entry.repo.clone(),
1741                    reason: "branches entries must not be empty".to_string(),
1742                });
1743            }
1744            // ADR-108 specifies exact name or a SINGLE-star wildcard per
1745            // branch pattern -- a pattern with two or more `*` (e.g. `**`,
1746            // `rel-*-*-final`) is a wider grammar than the ADR authorizes
1747            // and must be rejected at config load, not silently accepted.
1748            if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
1749                return Err(ConfigError::InvalidGitWriteEntry {
1750                    repo: entry.repo.clone(),
1751                    reason: format!(
1752                        "branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
1753                    ),
1754                });
1755            }
1756        }
1757
1758        if self.engines.is_empty() {
1759            return Ok(());
1760        }
1761
1762        let mut seen_names = std::collections::HashSet::new();
1763        for engine in &self.engines {
1764            if !seen_names.insert(engine.name.clone()) {
1765                return Err(ConfigError::DuplicateName {
1766                    name: engine.name.clone(),
1767                });
1768            }
1769            if parse_embedding_model_alias(&engine.model).is_none() {
1770                return Err(ConfigError::UnknownModel {
1771                    name: engine.name.clone(),
1772                    model: engine.model.clone(),
1773                });
1774            }
1775        }
1776
1777        let default_count = self.engines.iter().filter(|e| e.default).count();
1778        if default_count != 1 {
1779            return Err(ConfigError::DefaultCount {
1780                found: default_count,
1781            });
1782        }
1783
1784        // Reject non-finite fusion_weight explicitly: NaN doesn't satisfy `w <= 0.0`
1785        // and +inf is unbounded, so neither is caught by the range check alone.
1786        for engine in &self.engines {
1787            if let Some(w) = engine.fusion_weight {
1788                if !w.is_finite() || w <= 0.0 {
1789                    return Err(ConfigError::InvalidFusionWeight {
1790                        name: engine.name.clone(),
1791                        value: w,
1792                    });
1793                }
1794            }
1795        }
1796
1797        Ok(())
1798    }
1799
1800    /// Return the engine flagged `default = true`, or `None` if the list is empty.
1801    pub fn default_engine(&self) -> Option<&EngineConfig> {
1802        self.engines.iter().find(|e| e.default)
1803    }
1804}
1805
1806// ---- Env-var fallback ----
1807
1808/// Build an in-memory `KhiveConfig` from the legacy env-var path.
1809///
1810/// Used when no config file is present. Emits `tracing::info!` directing
1811/// operators to migrate to `~/.khive/config.toml`.
1812///
1813/// The primary model (`KHIVE_EMBEDDING_MODEL`) becomes the `default = true`
1814/// engine; additional models become non-default secondary engines. When only
1815/// `KHIVE_ADDITIONAL_EMBEDDING_MODELS` is set, the built-in default model is
1816/// synthesized as the primary — the additional list is additive, never a
1817/// replacement for the primary (khive#1221; matches `RuntimeConfig::default()`,
1818/// which resolves an unset `KHIVE_EMBEDDING_MODEL` to the built-in default).
1819pub fn config_from_env() -> KhiveConfig {
1820    let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
1821        .ok()
1822        .filter(|s| !s.trim().is_empty());
1823    let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
1824        .ok()
1825        .unwrap_or_default();
1826    let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
1827        .into_iter()
1828        .filter(|s| !s.is_empty())
1829        .collect();
1830
1831    if primary_model.is_none() && additional.is_empty() {
1832        return KhiveConfig::default();
1833    }
1834
1835    tracing::info!(
1836        "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
1837    );
1838
1839    config_from_env_parts(primary_model, additional)
1840}
1841
1842/// Pure core of [`config_from_env`], separated so the engine-list derivation
1843/// is testable without mutating process-global environment variables.
1844fn config_from_env_parts(primary_model: Option<String>, additional: Vec<String>) -> KhiveConfig {
1845    let mut engines = Vec::new();
1846
1847    let primary =
1848        primary_model.unwrap_or_else(|| lattice_embed::EmbeddingModel::AllMiniLmL6V2.to_string());
1849    engines.push(EngineConfig {
1850        name: "default".to_string(),
1851        model: primary.clone(),
1852        default: true,
1853        fusion_weight: None,
1854        dims: None,
1855    });
1856
1857    for (i, model) in additional.into_iter().enumerate() {
1858        // The additional list restating the primary is a no-op, not a second engine.
1859        if model.eq_ignore_ascii_case(&primary) {
1860            continue;
1861        }
1862        engines.push(EngineConfig {
1863            name: format!("engine-{}", i + 1),
1864            model,
1865            default: false,
1866            fusion_weight: None,
1867            dims: None,
1868        });
1869    }
1870
1871    KhiveConfig {
1872        engines,
1873        ..KhiveConfig::default()
1874    }
1875}
1876
1877// ---- Tests ----
1878
1879// Kept inline (not tests/): exercises private ConfigError variants not part
1880// of the public API.
1881#[cfg(test)]
1882mod tests {
1883    use super::*;
1884
1885    #[test]
1886    fn web_partial_ceiling_config_rejects_incoherent_effective_bounds_at_load() {
1887        let dir = tempfile::tempdir().unwrap();
1888        for (default_key, max_key, default, maximum) in [
1889            ("timeout_default_s", "timeout_max_s", 30_u64, 120_u64),
1890            (
1891                "max_bytes_default",
1892                "max_bytes_max",
1893                5 * 1024 * 1024,
1894                50 * 1024 * 1024,
1895            ),
1896            ("search_limit_default", "search_limit_max", 10, 50),
1897        ] {
1898            for invalid in [
1899                format!("{max_key}=1"),
1900                format!("{max_key}=0"),
1901                format!("{default_key}=0"),
1902                format!("{default_key}={}", maximum + 1),
1903                format!("{default_key}=2\n{max_key}=1"),
1904            ] {
1905                let path = write_toml(&dir, &format!("[web]\n{invalid}\n"));
1906                let error = KhiveConfig::load(Some(&path)).unwrap_err();
1907                assert!(
1908                    error.to_string().contains(default_key),
1909                    "{invalid}: {error}"
1910                );
1911            }
1912            for valid in [
1913                String::new(),
1914                format!("{max_key}={default}"),
1915                format!("{default_key}={maximum}"),
1916                format!("{default_key}=1\n{max_key}=1"),
1917            ] {
1918                let path = write_toml(&dir, &format!("[web]\n{valid}\n"));
1919                let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
1920                config.web.validate().unwrap();
1921            }
1922        }
1923    }
1924
1925    #[test]
1926    fn web_ceiling_programmatic_validation_rejects_unrepresentable_deadlines() {
1927        let config = WebSectionConfig {
1928            timeout_max_s: Some(u64::MAX),
1929            ..Default::default()
1930        };
1931        assert!(
1932            matches!(config.resolved_ceilings(), Err(ConfigError::InvalidWebConfig { key, .. }) if key == "timeout_max_s")
1933        );
1934        assert!(config.validate().is_err());
1935        let config = WebSectionConfig {
1936            max_bytes_max: Some(1),
1937            ..Default::default()
1938        };
1939        assert!(config.resolved_ceilings().is_err());
1940    }
1941
1942    fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
1943        let path = dir.path().join("config.toml");
1944        std::fs::write(&path, content).unwrap();
1945        path
1946    }
1947
1948    fn in_memory_runtime_config() -> crate::RuntimeConfig {
1949        crate::RuntimeConfig {
1950            db_path: None,
1951            ..crate::RuntimeConfig::no_embeddings()
1952        }
1953    }
1954
1955    /// Load errors must name the config file they came from (#1892): the
1956    /// validation and I/O errors gain the loader's `InFile` context, while
1957    /// `Parse` keeps carrying its own path. The message suffix is the
1958    /// user-facing contract.
1959    #[test]
1960    fn load_errors_name_the_config_file() {
1961        let dir = tempfile::tempdir().unwrap();
1962
1963        let gate = write_toml(&dir, "[gate]\nmode = \"x\"\n");
1964        let err = KhiveConfig::load(Some(&gate)).expect_err("unknown gate key must fail");
1965        assert!(
1966            err.to_string().contains(&gate.display().to_string()),
1967            "gate error must name the file, got: {err}"
1968        );
1969
1970        let invalid = write_toml(
1971            &dir,
1972            "[[engines]]\nname = \"a\"\nmodel = \"all-minilm-l6-v2\"\n",
1973        );
1974        let err = KhiveConfig::load(Some(&invalid)).expect_err("validation must fail");
1975        assert!(
1976            err.to_string().contains("(config file: "),
1977            "validation error must name the file, got: {err}"
1978        );
1979
1980        let parse = write_toml(&dir, "not = = toml");
1981        let err = KhiveConfig::load(Some(&parse)).expect_err("parse must fail");
1982        assert!(
1983            err.to_string().contains("config.toml"),
1984            "parse error must name the file, got: {err}"
1985        );
1986        assert!(
1987            matches!(err, ConfigError::Parse { .. }),
1988            "parse errors keep their own variant unwrapped, got: {err:?}"
1989        );
1990    }
1991
1992    /// Unwrap the loader's `InFile` context (asserting it names a real path)
1993    /// so variant-shape assertions test the underlying error.
1994    fn config_error_root(err: &ConfigError) -> &ConfigError {
1995        match err {
1996            ConfigError::InFile { path, source } => {
1997                assert!(
1998                    !path.as_os_str().is_empty(),
1999                    "InFile must carry the config path"
2000                );
2001                source
2002            }
2003            other => other,
2004        }
2005    }
2006
2007    // khive#1221: with no primary set, the additional list must ADD to the
2008    // built-in default primary, never replace it.
2009    #[test]
2010    fn env_additional_only_keeps_builtin_primary() {
2011        let cfg = super::config_from_env_parts(
2012            None,
2013            vec!["paraphrase-multilingual-minilm-l12-v2".to_string()],
2014        );
2015        assert_eq!(cfg.engines.len(), 2);
2016        let default_engine = cfg.default_engine().expect("a default engine");
2017        assert_eq!(default_engine.model, "all-minilm-l6-v2");
2018        assert!(
2019            cfg.engines
2020                .iter()
2021                .any(|e| !e.default && e.model == "paraphrase-multilingual-minilm-l12-v2"),
2022            "additional model must be a non-default secondary engine"
2023        );
2024    }
2025
2026    #[test]
2027    fn env_explicit_primary_stays_primary() {
2028        let cfg = super::config_from_env_parts(
2029            Some("paraphrase-multilingual-minilm-l12-v2".to_string()),
2030            vec![],
2031        );
2032        assert_eq!(cfg.engines.len(), 1);
2033        assert_eq!(
2034            cfg.default_engine().expect("default").model,
2035            "paraphrase-multilingual-minilm-l12-v2"
2036        );
2037    }
2038
2039    #[test]
2040    fn env_additional_restating_primary_is_deduped() {
2041        let cfg = super::config_from_env_parts(None, vec!["all-minilm-l6-v2".to_string()]);
2042        assert_eq!(cfg.engines.len(), 1);
2043        assert!(cfg.engines[0].default);
2044    }
2045
2046    #[test]
2047    fn test_load_minimal_config() {
2048        let dir = tempfile::tempdir().unwrap();
2049        let path = write_toml(
2050            &dir,
2051            r#"
2052[[engines]]
2053name = "x"
2054model = "all-minilm-l6-v2"
2055default = true
2056"#,
2057        );
2058        let cfg = KhiveConfig::load(Some(&path))
2059            .expect("load should succeed")
2060            .expect("file should be found");
2061        assert_eq!(cfg.engines.len(), 1);
2062        assert_eq!(cfg.engines[0].name, "x");
2063        assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
2064        assert!(cfg.engines[0].default);
2065    }
2066
2067    #[test]
2068    fn test_unknown_engine_model_rejected_before_conversion() {
2069        let dir = tempfile::tempdir().unwrap();
2070        let path = write_toml(
2071            &dir,
2072            "[[engines]]\nname = \"primary\"\nmodel = \"not-a-model\"\ndefault = true\n",
2073        );
2074        let err = KhiveConfig::load(Some(&path)).expect_err("unknown primary model must fail");
2075        assert!(
2076            matches!(
2077                config_error_root(&err),
2078                ConfigError::UnknownModel { name, model }
2079                    if name == "primary" && model == "not-a-model"
2080            ),
2081            "expected UnknownModel for the primary engine, got {err:?}"
2082        );
2083
2084        let config: KhiveConfig = toml::from_str(
2085            "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n\n[[engines]]\nname = \"secondary\"\nmodel = \"not-a-model\"\n",
2086        )
2087        .unwrap();
2088        assert!(matches!(
2089            config.validate(),
2090            Err(ConfigError::UnknownModel { name, model })
2091                if name == "secondary" && model == "not-a-model"
2092        ));
2093    }
2094
2095    #[test]
2096    fn test_recognized_engine_model_validates_and_converts() {
2097        let dir = tempfile::tempdir().unwrap();
2098        let path = write_toml(
2099            &dir,
2100            "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
2101        );
2102        let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
2103        config.validate().unwrap();
2104        let runtime = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
2105        assert_eq!(
2106            runtime.embedding_model,
2107            Some(lattice_embed::EmbeddingModel::AllMiniLmL6V2)
2108        );
2109    }
2110
2111    #[test]
2112    fn test_default_engine_required_when_engines_present() {
2113        let dir = tempfile::tempdir().unwrap();
2114        let path = write_toml(
2115            &dir,
2116            r#"
2117[[engines]]
2118name = "a"
2119model = "all-minilm-l6-v2"
2120"#,
2121        );
2122        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
2123        assert!(
2124            matches!(
2125                config_error_root(&err),
2126                ConfigError::DefaultCount { found: 0 }
2127            ),
2128            "expected DefaultCount {{ found: 0 }}, got {err:?}"
2129        );
2130    }
2131
2132    #[test]
2133    fn test_multiple_default_rejected() {
2134        let dir = tempfile::tempdir().unwrap();
2135        let path = write_toml(
2136            &dir,
2137            r#"
2138[[engines]]
2139name = "a"
2140model = "all-minilm-l6-v2"
2141default = true
2142
2143[[engines]]
2144name = "b"
2145model = "paraphrase-multilingual-minilm-l12-v2"
2146default = true
2147"#,
2148        );
2149        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
2150        assert!(
2151            matches!(
2152                config_error_root(&err),
2153                ConfigError::DefaultCount { found: 2 }
2154            ),
2155            "expected DefaultCount {{ found: 2 }}, got {err:?}"
2156        );
2157    }
2158
2159    #[test]
2160    fn test_fusion_weight_validation() {
2161        let dir = tempfile::tempdir().unwrap();
2162        let path = write_toml(
2163            &dir,
2164            r#"
2165[[engines]]
2166name = "a"
2167model = "all-minilm-l6-v2"
2168default = true
2169fusion_weight = -0.5
2170"#,
2171        );
2172        let err =
2173            KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
2174        assert!(
2175            matches!(
2176                config_error_root(&err),
2177                ConfigError::InvalidFusionWeight { .. }
2178            ),
2179            "expected InvalidFusionWeight, got {err:?}"
2180        );
2181
2182        let path2 = write_toml(
2183            &dir,
2184            r#"
2185[[engines]]
2186name = "a"
2187model = "all-minilm-l6-v2"
2188default = true
2189fusion_weight = 0.0
2190"#,
2191        );
2192        let err2 =
2193            KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
2194        assert!(
2195            matches!(
2196                config_error_root(&err2),
2197                ConfigError::InvalidFusionWeight { .. }
2198            ),
2199            "expected InvalidFusionWeight, got {err2:?}"
2200        );
2201    }
2202
2203    #[test]
2204    fn test_env_var_fallback() {
2205        let dir = tempfile::tempdir().unwrap();
2206        let absent = dir.path().join("missing.toml");
2207
2208        let loaded = KhiveConfig::load(Some(&absent)).unwrap();
2209        assert!(loaded.is_none());
2210
2211        // Can't safely set env vars in a parallel test suite, so exercise the
2212        // direct construction path instead.
2213        let primary = "all-minilm-l6-v2".to_string();
2214        let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
2215
2216        let mut engines = vec![EngineConfig {
2217            name: "default".to_string(),
2218            model: primary,
2219            default: true,
2220            fusion_weight: None,
2221            dims: None,
2222        }];
2223        for (i, model) in additional.into_iter().enumerate() {
2224            engines.push(EngineConfig {
2225                name: format!("engine-{}", i + 1),
2226                model,
2227                default: false,
2228                fusion_weight: None,
2229                dims: None,
2230            });
2231        }
2232        let cfg = KhiveConfig {
2233            engines,
2234            ..KhiveConfig::default()
2235        };
2236        cfg.validate().expect("env-derived config should be valid");
2237        assert_eq!(cfg.engines.len(), 2);
2238        assert!(cfg.default_engine().is_some());
2239        assert_eq!(cfg.default_engine().unwrap().name, "default");
2240    }
2241
2242    #[test]
2243    fn test_file_overrides_env() {
2244        let dir = tempfile::tempdir().unwrap();
2245        let path = write_toml(
2246            &dir,
2247            r#"
2248[[engines]]
2249name = "file-engine"
2250model = "all-minilm-l6-v2"
2251default = true
2252"#,
2253        );
2254
2255        // KhiveConfig::load returns the file config regardless of env vars;
2256        // warning-on-conflict is the caller's responsibility.
2257        let cfg = KhiveConfig::load(Some(&path))
2258            .expect("load should succeed")
2259            .expect("file should be present");
2260        assert_eq!(cfg.engines[0].name, "file-engine");
2261    }
2262
2263    #[test]
2264    fn test_duplicate_engine_names_rejected() {
2265        let dir = tempfile::tempdir().unwrap();
2266        let path = write_toml(
2267            &dir,
2268            r#"
2269[[engines]]
2270name = "shared"
2271model = "all-minilm-l6-v2"
2272default = true
2273
2274[[engines]]
2275name = "shared"
2276model = "paraphrase-multilingual-minilm-l12-v2"
2277"#,
2278        );
2279        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
2280        assert!(
2281            matches!(config_error_root(&err), ConfigError::DuplicateName { .. }),
2282            "expected DuplicateName, got {err:?}"
2283        );
2284    }
2285
2286    #[test]
2287    fn test_empty_config_is_valid() {
2288        let dir = tempfile::tempdir().unwrap();
2289        let path = write_toml(&dir, "# no engines\n");
2290        let cfg = KhiveConfig::load(Some(&path))
2291            .expect("load should succeed")
2292            .expect("file should be found");
2293        assert!(cfg.engines.is_empty());
2294        cfg.validate().expect("empty config should be valid");
2295    }
2296
2297    #[test]
2298    fn runtime_blob_hydration_budget_parses_and_resolves_before_engine_early_return() {
2299        use crate::runtime::runtime_config_from_khive_config;
2300        use crate::RuntimeConfig;
2301
2302        let dir = tempfile::tempdir().unwrap();
2303        let path = write_toml(
2304            &dir,
2305            r#"
2306[runtime]
2307blob_hydration_bytes = 134217728
2308"#,
2309        );
2310        let cfg = KhiveConfig::load(Some(&path))
2311            .expect("load should succeed")
2312            .expect("file should be found");
2313
2314        assert_eq!(cfg.runtime.blob_hydration_bytes, Some(134_217_728));
2315        let resolved = runtime_config_from_khive_config(&cfg, RuntimeConfig::default());
2316        assert_eq!(resolved.blob_hydration_bytes, 134_217_728);
2317    }
2318
2319    #[test]
2320    fn runtime_blob_hydration_budget_below_one_whole_blob_is_rejected() {
2321        let dir = tempfile::tempdir().unwrap();
2322        let value = khive_storage::MAX_BLOB_WHOLE_BYTES - 1;
2323        let path = write_toml(
2324            &dir,
2325            &format!("[runtime]\nblob_hydration_bytes = {value}\n"),
2326        );
2327
2328        let err = KhiveConfig::load(Some(&path)).expect_err("undersized budget must fail closed");
2329        assert!(
2330            matches!(
2331                config_error_root(&err),
2332                ConfigError::InvalidBlobHydrationBytes {
2333                    value: actual,
2334                    min,
2335                    ..
2336                } if *actual == value && *min == khive_storage::MAX_BLOB_WHOLE_BYTES
2337            ),
2338            "got {err:?}"
2339        );
2340    }
2341
2342    #[test]
2343    fn runtime_blob_hydration_budget_accepts_the_inclusive_portable_minimum() {
2344        let dir = tempfile::tempdir().unwrap();
2345        let value = khive_storage::MAX_BLOB_WHOLE_BYTES;
2346        let path = write_toml(
2347            &dir,
2348            &format!("[runtime]\nblob_hydration_bytes = {value}\n"),
2349        );
2350
2351        let cfg = KhiveConfig::load(Some(&path))
2352            .expect("the inclusive minimum must be valid")
2353            .expect("config should exist");
2354        assert_eq!(cfg.runtime.blob_hydration_bytes, Some(value));
2355    }
2356
2357    #[test]
2358    fn runtime_blob_hydration_budget_above_semaphore_capacity_is_rejected() {
2359        let dir = tempfile::tempdir().unwrap();
2360        let max = tokio::sync::Semaphore::MAX_PERMITS as u64;
2361        let value = max
2362            .checked_add(1)
2363            .expect("tokio maximum fits below u64::MAX");
2364        let path = write_toml(
2365            &dir,
2366            &format!("[runtime]\nblob_hydration_bytes = {value}\n"),
2367        );
2368
2369        let err = KhiveConfig::load(Some(&path)).expect_err("oversized budget must fail closed");
2370        assert!(
2371            matches!(
2372                config_error_root(&err),
2373                ConfigError::InvalidBlobHydrationBytes {
2374                    value: actual,
2375                    max: actual_max,
2376                    ..
2377                } if *actual == value && *actual_max == max
2378            ),
2379            "got {err:?}"
2380        );
2381    }
2382
2383    #[test]
2384    fn test_multi_engine_positive_fusion_weight() {
2385        let dir = tempfile::tempdir().unwrap();
2386        let path = write_toml(
2387            &dir,
2388            r#"
2389[[engines]]
2390name = "primary"
2391model = "all-minilm-l6-v2"
2392default = true
2393fusion_weight = 0.7
2394
2395[[engines]]
2396name = "secondary"
2397model = "paraphrase-multilingual-minilm-l12-v2"
2398fusion_weight = 0.3
2399"#,
2400        );
2401        let cfg = KhiveConfig::load(Some(&path))
2402            .expect("load should succeed")
2403            .expect("file should be found");
2404        assert_eq!(cfg.engines.len(), 2);
2405        assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
2406        assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
2407    }
2408
2409    #[test]
2410    fn test_actor_id_parsed() {
2411        let dir = tempfile::tempdir().unwrap();
2412        let path = write_toml(
2413            &dir,
2414            r#"
2415[actor]
2416id = "lambda:khive"
2417display_name = "example actor"
2418"#,
2419        );
2420        let cfg = KhiveConfig::load(Some(&path))
2421            .expect("load should succeed")
2422            .expect("file should be found");
2423        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
2424        assert_eq!(cfg.actor.display_name.as_deref(), Some("example actor"));
2425        assert!(cfg.engines.is_empty());
2426    }
2427
2428    #[test]
2429    fn gate_mailbox_reader_config_loads_exact_labels_and_rejects_bad_policy() {
2430        let dir = tempfile::tempdir().unwrap();
2431        let path = write_toml(
2432            &dir,
2433            "[actor]\nid = \"lambda:owner\"\nmailbox_readers = [\"lambda:helper\", \"助手/审阅者\", \"lambda:helper\"]\n",
2434        );
2435        let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
2436        assert_eq!(
2437            config.actor.mailbox_readers,
2438            ["lambda:helper", "助手/审阅者", "lambda:helper"]
2439        );
2440
2441        for (owner, readers) in [
2442            (None, vec!["reader".to_string()]),
2443            (Some("local"), vec!["reader".to_string()]),
2444            (Some("lambda:owner"), vec!["local".to_string()]),
2445            (Some("lambda:owner"), vec![String::new()]),
2446            (Some("lambda:owner"), vec![" \t".to_string()]),
2447            (Some("lambda:owner"), vec!["bad\nactor".to_string()]),
2448            (Some("lambda:owner"), vec!["x".repeat(256)]),
2449            (Some("lambda:owner"), vec!["reader".to_string(); 257]),
2450        ] {
2451            let config = KhiveConfig {
2452                actor: ActorConfig {
2453                    id: owner.map(str::to_string),
2454                    mailbox_readers: readers,
2455                    ..Default::default()
2456                },
2457                ..Default::default()
2458            };
2459            assert!(matches!(
2460                config.validate(),
2461                Err(ConfigError::InvalidMailboxReaders { .. })
2462            ));
2463        }
2464        for source in [
2465            "[actor]\nmailbox_readers = [\"reader\"]\n",
2466            "[actor]\nid = \"local\"\nmailbox_readers = [\"reader\"]\n",
2467            "[actor]\nid = \"lambda:owner\"\nmailbox_readers = [\"\"]\n",
2468            "[actor]\nid = \"lambda:owner\"\nmailbox_readers = \"reader\"\n",
2469        ] {
2470            let path = write_toml(&dir, source);
2471            assert!(KhiveConfig::load(Some(&path)).is_err());
2472        }
2473        let boundary = KhiveConfig {
2474            actor: ActorConfig {
2475                id: Some("lambda:owner".into()),
2476                mailbox_readers: vec!["x".repeat(255); 256],
2477                ..Default::default()
2478            },
2479            ..Default::default()
2480        };
2481        boundary.validate().unwrap();
2482        KhiveConfig::default().validate().unwrap();
2483    }
2484
2485    #[test]
2486    fn test_actor_and_engines_together() {
2487        let dir = tempfile::tempdir().unwrap();
2488        let path = write_toml(
2489            &dir,
2490            r#"
2491[actor]
2492id = "lambda:test"
2493
2494[[engines]]
2495name = "default"
2496model = "all-minilm-l6-v2"
2497default = true
2498"#,
2499        );
2500        let cfg = KhiveConfig::load(Some(&path))
2501            .expect("load should succeed")
2502            .expect("file should be found");
2503        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
2504        assert_eq!(cfg.engines.len(), 1);
2505    }
2506
2507    #[test]
2508    fn test_actor_absent_defaults_to_none() {
2509        let dir = tempfile::tempdir().unwrap();
2510        let path = write_toml(
2511            &dir,
2512            r#"
2513[[engines]]
2514name = "x"
2515model = "all-minilm-l6-v2"
2516default = true
2517"#,
2518        );
2519        let cfg = KhiveConfig::load(Some(&path))
2520            .expect("load should succeed")
2521            .expect("file should be found");
2522        assert!(
2523            cfg.actor.id.is_none(),
2524            "actor.id must be None when [actor] section is absent"
2525        );
2526    }
2527
2528    #[test]
2529    fn test_load_with_home_fallback_no_files() {
2530        let project_dir = tempfile::tempdir().unwrap();
2531        let home_dir = tempfile::tempdir().unwrap();
2532        let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None);
2533        assert!(
2534            result.expect("no error expected").is_none(),
2535            "should return None when no config files exist in the given roots"
2536        );
2537    }
2538
2539    #[test]
2540    fn home_gate_config_loads_while_explicit_empty_config_is_hermetic() {
2541        let project_dir = tempfile::tempdir().unwrap();
2542        let home_dir = tempfile::tempdir().unwrap();
2543        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2544        std::fs::write(
2545            home_dir.path().join(".khive/config.toml"),
2546            "[gate]\ngranted_actors = [\"lambda:enrolled\"]\ndeny_writes_for = [\"*:duty\"]\n",
2547        )
2548        .unwrap();
2549
2550        let loaded = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
2551            .expect("supported home gate policy loads")
2552            .expect("home config exists");
2553        let gate = loaded.gate.expect("gate table");
2554        assert_eq!(gate.granted_actors, vec!["lambda:enrolled"]);
2555        assert_eq!(gate.deny_writes_for, vec!["*:duty"]);
2556
2557        let empty = project_dir.path().join("empty-khive-config.toml");
2558        std::fs::write(&empty, "").unwrap();
2559        let isolated = KhiveConfig::load_with_home_fallback(Some(&empty), None)
2560            .expect("an explicit empty fixture must isolate config discovery")
2561            .expect("the explicit config exists");
2562        assert!(isolated.engines.is_empty());
2563        assert!(isolated.actor.id.is_none());
2564        assert!(isolated.gate.is_none());
2565    }
2566
2567    #[test]
2568    fn test_load_with_home_fallback_explicit_path() {
2569        let dir = tempfile::tempdir().unwrap();
2570        let path = write_toml(
2571            &dir,
2572            r#"
2573[actor]
2574id = "lambda:explicit"
2575"#,
2576        );
2577        let cfg = KhiveConfig::load_with_home_fallback(Some(&path), None)
2578            .expect("no error expected")
2579            .expect("file found");
2580        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
2581    }
2582
2583    #[test]
2584    fn load_with_home_fallback_and_source_names_selected_file() {
2585        let project_dir = tempfile::tempdir().unwrap();
2586        let home_dir = tempfile::tempdir().unwrap();
2587        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2588        let selected = home_dir.path().join(".khive/config.toml");
2589        std::fs::write(&selected, "[actor]\nid = \"lambda:home\"\n").unwrap();
2590
2591        let (config, source) = KhiveConfig::load_with_roots_and_source(
2592            project_dir.path(),
2593            Some(home_dir.path()),
2594            None,
2595        )
2596        .expect("load should succeed")
2597        .expect("home fallback should be selected");
2598
2599        assert_eq!(config.actor.id.as_deref(), Some("lambda:home"));
2600        assert_eq!(
2601            source,
2602            std::fs::canonicalize(selected).expect("canonical selected config path")
2603        );
2604    }
2605
2606    #[test]
2607    fn test_invalid_actor_id_rejected_at_load() {
2608        let dir = tempfile::tempdir().unwrap();
2609        let path = write_toml(
2610            &dir,
2611            r#"
2612[actor]
2613id = "bad namespace"
2614"#,
2615        );
2616        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
2617        assert!(
2618            matches!(config_error_root(&err), ConfigError::InvalidActorId { .. }),
2619            "expected InvalidActorId, got {err:?}"
2620        );
2621    }
2622
2623    #[test]
2624    fn test_empty_actor_id_rejected() {
2625        let dir = tempfile::tempdir().unwrap();
2626        let path = write_toml(
2627            &dir,
2628            r#"
2629[actor]
2630id = ""
2631"#,
2632        );
2633        let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
2634        assert!(
2635            matches!(config_error_root(&err), ConfigError::InvalidActorId { .. }),
2636            "expected InvalidActorId for empty string, got {err:?}"
2637        );
2638    }
2639
2640    #[test]
2641    fn test_malformed_actor_id_lambda_colon_only() {
2642        let dir = tempfile::tempdir().unwrap();
2643        let path = write_toml(
2644            &dir,
2645            r#"
2646[actor]
2647id = "lambda:"
2648"#,
2649        );
2650        let err =
2651            KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
2652        assert!(
2653            matches!(config_error_root(&err), ConfigError::InvalidActorId { .. }),
2654            "expected InvalidActorId for 'lambda:', got {err:?}"
2655        );
2656    }
2657
2658    // actor.id must not become default_namespace: writes stay pinned to `local`
2659    // even though a non-local actor.id widens the default read visible-set.
2660    #[test]
2661    fn test_runtime_config_actor_id_does_not_override_namespace() {
2662        use crate::runtime::runtime_config_from_khive_config;
2663        use crate::RuntimeConfig;
2664        use khive_types::namespace::Namespace;
2665
2666        let cfg = KhiveConfig {
2667            engines: vec![],
2668            actor: ActorConfig {
2669                id: Some("lambda:test-actor".to_string()),
2670                display_name: None,
2671                ..Default::default()
2672            },
2673            ..KhiveConfig::default()
2674        };
2675        cfg.validate().expect("valid config");
2676
2677        let base = RuntimeConfig::default();
2678        let result = runtime_config_from_khive_config(&cfg, base);
2679        assert_eq!(
2680            result.default_namespace,
2681            Namespace::local(),
2682            "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
2683             writes stay pinned to local"
2684        );
2685        // actor.id must also appear in visible_namespaces: the load-bearing
2686        // side effect that widens default reads to {local} ∪ {actor namespace}.
2687        assert!(
2688            result
2689                .visible_namespaces
2690                .contains(&Namespace::parse("lambda:test-actor").unwrap()),
2691            "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
2692             got: {:?}",
2693            result.visible_namespaces
2694        );
2695    }
2696
2697    #[test]
2698    fn test_runtime_config_no_actor_preserves_base() {
2699        use crate::runtime::runtime_config_from_khive_config;
2700        use crate::RuntimeConfig;
2701        use khive_types::namespace::Namespace;
2702
2703        let cfg = KhiveConfig {
2704            engines: vec![],
2705            actor: ActorConfig {
2706                id: None,
2707                display_name: None,
2708                ..Default::default()
2709            },
2710            ..KhiveConfig::default()
2711        };
2712        cfg.validate().expect("valid config");
2713
2714        let base_ns = Namespace::parse("lambda:base").unwrap();
2715        let base = RuntimeConfig {
2716            default_namespace: base_ns.clone(),
2717            ..RuntimeConfig::default()
2718        };
2719        let result = runtime_config_from_khive_config(&cfg, base);
2720        assert_eq!(
2721            result.default_namespace, base_ns,
2722            "no actor.id must leave base namespace unchanged"
2723        );
2724    }
2725
2726    #[test]
2727    fn test_load_with_home_fallback_project_root_over_hidden() {
2728        let dir = tempfile::tempdir().unwrap();
2729
2730        // Write .khive/config.toml (tier 3).
2731        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
2732        std::fs::write(
2733            dir.path().join(".khive/config.toml"),
2734            "[actor]\nid = \"lambda:hidden\"\n",
2735        )
2736        .unwrap();
2737
2738        // Write khive.toml (tier 2) — should win.
2739        std::fs::write(
2740            dir.path().join("khive.toml"),
2741            "[actor]\nid = \"lambda:project-root\"\n",
2742        )
2743        .unwrap();
2744
2745        let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
2746            .expect("no error expected")
2747            .expect("file should be found");
2748        assert_eq!(
2749            cfg.actor.id.as_deref(),
2750            Some("lambda:project-root"),
2751            "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
2752        );
2753    }
2754
2755    #[test]
2756    fn test_load_with_home_fallback_hidden_over_absent_root() {
2757        let dir = tempfile::tempdir().unwrap();
2758
2759        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
2760        std::fs::write(
2761            dir.path().join(".khive/config.toml"),
2762            "[actor]\nid = \"lambda:hidden-config\"\n",
2763        )
2764        .unwrap();
2765        // No khive.toml.
2766
2767        let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
2768            .expect("no error expected")
2769            .expect("file should be found");
2770        assert_eq!(
2771            cfg.actor.id.as_deref(),
2772            Some("lambda:hidden-config"),
2773            ".khive/config.toml (tier 3) must be found when khive.toml is absent"
2774        );
2775    }
2776
2777    #[test]
2778    fn test_load_with_roots_home_tier_found() {
2779        let project_dir = tempfile::tempdir().unwrap();
2780        let home_dir = tempfile::tempdir().unwrap();
2781
2782        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2783        std::fs::write(
2784            home_dir.path().join(".khive/config.toml"),
2785            "[actor]\nid = \"lambda:user-global\"\n",
2786        )
2787        .unwrap();
2788        // No project-level files.
2789
2790        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
2791            .expect("no error expected")
2792            .expect("file should be found");
2793        assert_eq!(
2794            cfg.actor.id.as_deref(),
2795            Some("lambda:user-global"),
2796            "~/.khive/config.toml (tier 4) must be found when project files absent"
2797        );
2798    }
2799
2800    #[test]
2801    fn test_load_with_roots_project_wins_over_home() {
2802        let project_dir = tempfile::tempdir().unwrap();
2803        let home_dir = tempfile::tempdir().unwrap();
2804
2805        // Home has a config.
2806        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2807        std::fs::write(
2808            home_dir.path().join(".khive/config.toml"),
2809            "[actor]\nid = \"lambda:user-global\"\n",
2810        )
2811        .unwrap();
2812
2813        // Project also has a config — should win.
2814        std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
2815        std::fs::write(
2816            project_dir.path().join(".khive/config.toml"),
2817            "[actor]\nid = \"lambda:project-wins\"\n",
2818        )
2819        .unwrap();
2820
2821        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
2822            .expect("no error expected")
2823            .expect("file should be found");
2824        assert_eq!(
2825            cfg.actor.id.as_deref(),
2826            Some("lambda:project-wins"),
2827            "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
2828        );
2829    }
2830
2831    // ── tier-3 db-dir anchor tests (config discovery canonicalization) ─────
2832
2833    // Two different process working directories, targeting the same database,
2834    // must resolve the identical tier-3 config file. Each cwd also carries its
2835    // own decoy `.khive/config.toml` so the test fails loudly (mismatched
2836    // actor ids) if the resolver ever falls back to the old cwd anchor instead
2837    // of the db-dir anchor.
2838    #[test]
2839    fn test_load_with_roots_same_db_different_cwd_resolves_identical_config() {
2840        let cwd_a = tempfile::tempdir().unwrap();
2841        let cwd_b = tempfile::tempdir().unwrap();
2842
2843        // Decoy cwd-anchored configs — must NOT be picked up once anchoring
2844        // moves to the db directory.
2845        std::fs::create_dir_all(cwd_a.path().join(".khive")).unwrap();
2846        std::fs::write(
2847            cwd_a.path().join(".khive/config.toml"),
2848            "[actor]\nid = \"lambda:wrong-cwd-a\"\n",
2849        )
2850        .unwrap();
2851        std::fs::create_dir_all(cwd_b.path().join(".khive")).unwrap();
2852        std::fs::write(
2853            cwd_b.path().join(".khive/config.toml"),
2854            "[actor]\nid = \"lambda:wrong-cwd-b\"\n",
2855        )
2856        .unwrap();
2857
2858        // The database and its co-located config live under a THIRD root,
2859        // distinct from either simulated cwd.
2860        let db_root = tempfile::tempdir().unwrap();
2861        let khive_dir = db_root.path().join(".khive");
2862        std::fs::create_dir_all(&khive_dir).unwrap();
2863        let db_path = khive_dir.join("khive.db");
2864        std::fs::write(&db_path, b"").unwrap(); // must exist for canonicalize to succeed
2865        std::fs::write(
2866            khive_dir.join("config.toml"),
2867            "[actor]\nid = \"lambda:db-anchored\"\n",
2868        )
2869        .unwrap();
2870
2871        let cfg_a = KhiveConfig::load_with_roots(cwd_a.path(), None, Some(&db_path))
2872            .expect("no error expected")
2873            .expect("db-anchored config must be found from cwd A");
2874        let cfg_b = KhiveConfig::load_with_roots(cwd_b.path(), None, Some(&db_path))
2875            .expect("no error expected")
2876            .expect("db-anchored config must be found from cwd B");
2877
2878        assert_eq!(
2879            cfg_a.actor.id.as_deref(),
2880            Some("lambda:db-anchored"),
2881            "cwd A must resolve the db-anchored config, not its own decoy"
2882        );
2883        assert_eq!(
2884            cfg_b.actor.id.as_deref(),
2885            Some("lambda:db-anchored"),
2886            "cwd B must resolve the db-anchored config, not its own decoy"
2887        );
2888        assert_eq!(
2889            cfg_a.actor.id, cfg_b.actor.id,
2890            "two processes at different cwds targeting the same db must resolve \
2891             identical config, killing config_id drift between client and daemon"
2892        );
2893    }
2894
2895    // Explicit `--config`/`KHIVE_CONFIG` (tier 1) must still win over the new
2896    // db-dir anchor (tier 3) — precedence is preserved, only the tier-3 anchor
2897    // moved.
2898    #[test]
2899    fn test_load_with_home_fallback_explicit_config_wins_over_db_anchor() {
2900        let explicit_dir = tempfile::tempdir().unwrap();
2901        let explicit_path = write_toml(&explicit_dir, "[actor]\nid = \"lambda:explicit-wins\"\n");
2902
2903        let db_root = tempfile::tempdir().unwrap();
2904        let khive_dir = db_root.path().join(".khive");
2905        std::fs::create_dir_all(&khive_dir).unwrap();
2906        let db_path = khive_dir.join("khive.db");
2907        std::fs::write(&db_path, b"").unwrap();
2908        std::fs::write(
2909            khive_dir.join("config.toml"),
2910            "[actor]\nid = \"lambda:db-anchor-loses\"\n",
2911        )
2912        .unwrap();
2913
2914        let cfg = KhiveConfig::load_with_home_fallback(Some(&explicit_path), Some(&db_path))
2915            .expect("no error expected")
2916            .expect("explicit path must be found");
2917        assert_eq!(
2918            cfg.actor.id.as_deref(),
2919            Some("lambda:explicit-wins"),
2920            "explicit --config/KHIVE_CONFIG must win over the db-dir anchor"
2921        );
2922    }
2923
2924    // Tier 4 (`~/.khive/config.toml`) must still be reached when the db-anchored
2925    // tier-3 directory has no `config.toml` alongside it.
2926    #[test]
2927    fn test_load_with_roots_home_fallback_reached_when_db_anchor_has_no_config() {
2928        let cwd = tempfile::tempdir().unwrap();
2929        let home_dir = tempfile::tempdir().unwrap();
2930        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2931        std::fs::write(
2932            home_dir.path().join(".khive/config.toml"),
2933            "[actor]\nid = \"lambda:home-fallback\"\n",
2934        )
2935        .unwrap();
2936
2937        // A real db directory that exists but has no co-located config.toml.
2938        let db_root = tempfile::tempdir().unwrap();
2939        let khive_dir = db_root.path().join(".khive");
2940        std::fs::create_dir_all(&khive_dir).unwrap();
2941        let db_path = khive_dir.join("khive.db");
2942        std::fs::write(&db_path, b"").unwrap();
2943
2944        let cfg = KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&db_path))
2945            .expect("no error expected")
2946            .expect("home-tier config must be found");
2947        assert_eq!(
2948            cfg.actor.id.as_deref(),
2949            Some("lambda:home-fallback"),
2950            "tier 4 (~/.khive/config.toml) must still be reached when the db-anchored \
2951             tier-3 directory has no config.toml"
2952        );
2953    }
2954
2955    // Cold start: the database file does not exist yet (first run). Anchor
2956    // resolution must not panic and must fall through the remaining tiers.
2957    #[test]
2958    fn test_load_with_roots_nonexistent_db_path_does_not_panic_and_falls_through() {
2959        let cwd = tempfile::tempdir().unwrap();
2960        let home_dir = tempfile::tempdir().unwrap();
2961        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2962        std::fs::write(
2963            home_dir.path().join(".khive/config.toml"),
2964            "[actor]\nid = \"lambda:home-cold-start\"\n",
2965        )
2966        .unwrap();
2967
2968        // Absolute path under a directory tree that was never created.
2969        let nonexistent_db = cwd.path().join("never-created/.khive/khive.db");
2970
2971        let cfg =
2972            KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&nonexistent_db))
2973                .expect("cold-start db path must not error or panic")
2974                .expect("home-tier config must still be found");
2975        assert_eq!(
2976            cfg.actor.id.as_deref(),
2977            Some("lambda:home-cold-start"),
2978            "a nonexistent db path (cold start) must fall through to tier 4, not panic"
2979        );
2980    }
2981
2982    // Cold start with a *relative* nonexistent db path exercises the
2983    // cwd-join fallback branch specifically (as opposed to the
2984    // already-absolute fallback branch above). Must not panic; no config
2985    // exists anywhere so the result is `Ok(None)`.
2986    #[test]
2987    fn test_load_with_roots_relative_nonexistent_db_path_does_not_panic() {
2988        let cwd = tempfile::tempdir().unwrap();
2989        let relative_db = PathBuf::from("never-created/.khive/khive.db");
2990
2991        let result = KhiveConfig::load_with_roots(cwd.path(), None, Some(&relative_db));
2992        assert!(
2993            result.is_ok(),
2994            "relative cold-start db path must not error or panic: {result:?}"
2995        );
2996        assert!(
2997            result.unwrap().is_none(),
2998            "no config exists anywhere in this test; result must be None"
2999        );
3000    }
3001
3002    // ── ADR-028 backend / pack config tests ─────────────────────────────────
3003
3004    #[test]
3005    fn test_no_backends_section_is_valid() {
3006        let dir = tempfile::tempdir().unwrap();
3007        let path = write_toml(
3008            &dir,
3009            r#"
3010[[engines]]
3011name = "default"
3012model = "all-minilm-l6-v2"
3013default = true
3014"#,
3015        );
3016        let cfg = KhiveConfig::load(Some(&path))
3017            .expect("no error")
3018            .expect("file found");
3019        assert!(cfg.backends.is_empty());
3020        assert!(cfg.packs.is_empty());
3021    }
3022
3023    #[test]
3024    fn test_single_sqlite_backend_parses() {
3025        let dir = tempfile::tempdir().unwrap();
3026        let path = write_toml(
3027            &dir,
3028            r#"
3029[[backends]]
3030name = "knowledge"
3031kind = "sqlite"
3032path = "/tmp/knowledge.db"
3033"#,
3034        );
3035        let cfg = KhiveConfig::load(Some(&path))
3036            .expect("no error")
3037            .expect("file found");
3038        assert_eq!(cfg.backends.len(), 1);
3039        let b = &cfg.backends[0];
3040        assert_eq!(b.name, "knowledge");
3041        assert!(matches!(b.kind, BackendKind::Sqlite));
3042        assert_eq!(
3043            b.path.as_ref().and_then(|p| p.to_str()),
3044            Some("/tmp/knowledge.db")
3045        );
3046    }
3047
3048    #[test]
3049    fn test_memory_backend_parses() {
3050        let dir = tempfile::tempdir().unwrap();
3051        let path = write_toml(
3052            &dir,
3053            r#"
3054[[backends]]
3055name = "ephemeral"
3056kind = "memory"
3057"#,
3058        );
3059        let cfg = KhiveConfig::load(Some(&path))
3060            .expect("no error")
3061            .expect("file found");
3062        assert_eq!(cfg.backends.len(), 1);
3063        assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
3064    }
3065
3066    #[test]
3067    fn test_pack_backend_assignment_parses() {
3068        let dir = tempfile::tempdir().unwrap();
3069        let path = write_toml(
3070            &dir,
3071            r#"
3072[[backends]]
3073name = "knowledge"
3074kind = "memory"
3075
3076[packs.knowledge]
3077backend = "knowledge"
3078"#,
3079        );
3080        let cfg = KhiveConfig::load(Some(&path))
3081            .expect("no error")
3082            .expect("file found");
3083        assert_eq!(cfg.packs.len(), 1);
3084        let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
3085        assert_eq!(pc.backend, "knowledge");
3086    }
3087
3088    #[test]
3089    fn test_duplicate_backend_name_rejected() {
3090        let dir = tempfile::tempdir().unwrap();
3091        let path = write_toml(
3092            &dir,
3093            r#"
3094[[backends]]
3095name = "dup"
3096kind = "memory"
3097
3098[[backends]]
3099name = "dup"
3100kind = "memory"
3101"#,
3102        );
3103        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
3104        assert!(
3105            matches!(config_error_root(&err), ConfigError::DuplicateBackendName { ref name } if name == "dup"),
3106            "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
3107        );
3108    }
3109
3110    #[test]
3111    fn test_empty_backend_name_rejected() {
3112        let dir = tempfile::tempdir().unwrap();
3113        let path = write_toml(
3114            &dir,
3115            r#"
3116[[backends]]
3117name = ""
3118kind = "memory"
3119"#,
3120        );
3121        let err = KhiveConfig::load(Some(&path)).expect_err("empty backend name must fail");
3122        assert!(
3123            matches!(config_error_root(&err), ConfigError::InvalidBackendName { ref name, .. } if name.is_empty()),
3124            "expected InvalidBackendName for the empty name, got {err:?}"
3125        );
3126    }
3127
3128    #[test]
3129    fn test_backend_served_kinds_absent_and_declared() {
3130        let dir = tempfile::tempdir().unwrap();
3131        let path = write_toml(
3132            &dir,
3133            r#"
3134[[backends]]
3135name = "legacy"
3136kind = "memory"
3137
3138[[backends]]
3139name = "notes"
3140kind = "memory"
3141served_kinds = ["note", "event"]
3142"#,
3143        );
3144        let config = KhiveConfig::load(Some(&path))
3145            .expect("valid served-kind declarations")
3146            .expect("config file found");
3147
3148        assert!(config.backends[0].served_kinds.is_none());
3149        assert_eq!(
3150            config.backends[1].served_kinds,
3151            Some(BTreeSet::from([SubstrateKind::Note, SubstrateKind::Event]))
3152        );
3153    }
3154
3155    #[test]
3156    fn test_empty_backend_served_kinds_rejected() {
3157        let dir = tempfile::tempdir().unwrap();
3158        let path = write_toml(
3159            &dir,
3160            r#"
3161[[backends]]
3162name = "main"
3163kind = "memory"
3164served_kinds = []
3165"#,
3166        );
3167        let error = KhiveConfig::load(Some(&path))
3168            .expect_err("an explicit empty served-kind declaration must fail closed");
3169
3170        assert!(matches!(
3171            config_error_root(&error),
3172            ConfigError::EmptyBackendServedKinds { name } if name == "main"
3173        ));
3174    }
3175
3176    #[test]
3177    fn test_unknown_backend_served_kind_rejected() {
3178        let dir = tempfile::tempdir().unwrap();
3179        let path = write_toml(
3180            &dir,
3181            r#"
3182[[backends]]
3183name = "main"
3184kind = "memory"
3185served_kinds = ["asset"]
3186"#,
3187        );
3188        let error = KhiveConfig::load(Some(&path))
3189            .expect_err("served-kind declarations use a closed vocabulary");
3190
3191        assert!(matches!(
3192            config_error_root(&error),
3193            ConfigError::Parse { .. }
3194        ));
3195        assert!(error.to_string().contains("unknown variant `asset`"));
3196    }
3197
3198    #[test]
3199    fn test_pack_referencing_undefined_backend_rejected() {
3200        let dir = tempfile::tempdir().unwrap();
3201        let path = write_toml(
3202            &dir,
3203            r#"
3204[[backends]]
3205name = "knowledge"
3206kind = "memory"
3207
3208[packs.kg]
3209backend = "nonexistent"
3210"#,
3211        );
3212        let err =
3213            KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
3214        assert!(
3215            matches!(config_error_root(&err), ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
3216                if pack == "kg" && backend == "nonexistent"),
3217            "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
3218        );
3219    }
3220
3221    #[test]
3222    fn test_pack_config_without_backends_section_is_allowed() {
3223        let dir = tempfile::tempdir().unwrap();
3224        // Explicit pack routes may name the implicit main backend.
3225        let path = write_toml(
3226            &dir,
3227            r#"
3228[packs.kg]
3229backend = "main"
3230"#,
3231        );
3232        let cfg = KhiveConfig::load(Some(&path))
3233            .expect("no error expected")
3234            .expect("file found");
3235        assert_eq!(cfg.backends.len(), 0);
3236        assert_eq!(cfg.packs.len(), 1);
3237    }
3238
3239    #[test]
3240    fn test_implicit_main_rejects_unknown_pack_backend() {
3241        let dir = tempfile::tempdir().unwrap();
3242        let path = write_toml(&dir, "[packs.comm]\nbackend = 'does-not-exist'\n");
3243        let error = KhiveConfig::load(Some(&path)).expect_err("unknown route must fail");
3244        assert!(matches!(
3245            config_error_root(&error),
3246            ConfigError::UnknownPackBackend { pack, backend, defined }
3247                if pack == "comm" && backend == "does-not-exist" && defined == "main"
3248        ));
3249    }
3250
3251    #[test]
3252    fn test_backend_search_coverage_rejects_missing_substrates() {
3253        for (served, missing) in [
3254            ("'note'", vec![SubstrateKind::Entity]),
3255            ("'entity'", vec![SubstrateKind::Note]),
3256            ("'event'", vec![SubstrateKind::Note, SubstrateKind::Entity]),
3257        ] {
3258            let dir = tempfile::tempdir().unwrap();
3259            let path = write_toml(
3260                &dir,
3261                &format!(
3262                    "[[backends]]\nname = 'main'\nkind = 'memory'\nserved_kinds = [{served}]\n"
3263                ),
3264            );
3265            let error = KhiveConfig::load(Some(&path)).expect_err("incomplete coverage");
3266            assert!(
3267                matches!(
3268                    config_error_root(&error),
3269                    ConfigError::MissingBackendSearchKinds { kinds, defined }
3270                        if kinds == &missing && defined == "main"
3271                ),
3272                "unexpected coverage error: {error}"
3273            );
3274        }
3275    }
3276
3277    #[test]
3278    fn test_backend_search_coverage_allows_split_substrates_and_event_only_secondary() {
3279        let dir = tempfile::tempdir().unwrap();
3280        let path = write_toml(
3281            &dir,
3282            r#"
3283[[backends]]
3284name = "main"
3285kind = "memory"
3286served_kinds = ["entity"]
3287
3288[[backends]]
3289name = "notes"
3290kind = "memory"
3291served_kinds = ["note"]
3292
3293[[backends]]
3294name = "events"
3295kind = "memory"
3296served_kinds = ["event"]
3297"#,
3298        );
3299        KhiveConfig::load(Some(&path)).expect("search coverage is the union of backends");
3300    }
3301
3302    #[test]
3303    fn test_backend_cache_mb_rejected_at_validate() {
3304        let dir = tempfile::tempdir().unwrap();
3305        let path = write_toml(
3306            &dir,
3307            r#"
3308[[backends]]
3309name = "main"
3310kind = "memory"
3311cache_mb = 128
3312"#,
3313        );
3314        let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
3315        assert!(
3316            matches!(config_error_root(&err), ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
3317            "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
3318        );
3319    }
3320
3321    #[test]
3322    fn test_backend_journal_mode_rejected_at_validate() {
3323        let dir = tempfile::tempdir().unwrap();
3324        let path = write_toml(
3325            &dir,
3326            r#"
3327[[backends]]
3328name = "main"
3329kind = "memory"
3330journal_mode = "wal"
3331"#,
3332        );
3333        let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
3334        assert!(
3335            matches!(config_error_root(&err), ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
3336            "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
3337        );
3338    }
3339
3340    // A top-level `db` key must be rejected loudly instead of silently
3341    // ignored as an unknown key by serde's forward-compatible default.
3342    #[test]
3343    fn test_top_level_db_rejected_at_validate() {
3344        let dir = tempfile::tempdir().unwrap();
3345        let path = write_toml(
3346            &dir,
3347            r#"
3348db = "/tmp/scratch/demo.db"
3349"#,
3350        );
3351        let err = KhiveConfig::load(Some(&path)).expect_err("top-level db must be rejected");
3352        assert!(
3353            matches!(config_error_root(&err), ConfigError::UnsupportedTopLevelDb { ref value } if value == "/tmp/scratch/demo.db"),
3354            "expected UnsupportedTopLevelDb {{ value: \"/tmp/scratch/demo.db\" }}, got {err:?}"
3355        );
3356    }
3357
3358    #[test]
3359    fn gate_caller_enrollment_config_loads_for_runtime_enforcement() {
3360        let dir = tempfile::tempdir().unwrap();
3361        let path = write_toml(
3362            &dir,
3363            r#"
3364[gate]
3365granted_actors = ["lambda:enrolled"]
3366grant_unattributed = false
3367"#,
3368        );
3369
3370        let config = KhiveConfig::load(Some(&path))
3371            .expect("the supported caller-enrollment policy must parse")
3372            .expect("config exists");
3373        let gate = config.gate.expect("gate section");
3374        assert_eq!(gate.granted_actors, vec!["lambda:enrolled"]);
3375        assert!(!gate.grant_unattributed);
3376    }
3377
3378    #[test]
3379    fn unknown_actor_key_fails_to_load() {
3380        let dir = tempfile::tempdir().unwrap();
3381
3382        // Control first, in the same test: the identical table carrying only
3383        // supported keys must load, so the refusal below is attributable to the
3384        // unknown key rather than to the fixture.
3385        let supported = write_toml(
3386            &dir,
3387            r#"
3388[actor]
3389id = "lambda:example"
3390visible_namespaces = ["lambda:other"]
3391"#,
3392        );
3393        KhiveConfig::load(Some(&supported))
3394            .expect("a config using only supported [actor] keys must parse")
3395            .expect("config exists");
3396
3397        // A `[gate]` key written one table too high. Silently discarding it
3398        // leaves anonymous admission at whatever it already was while the file
3399        // on disk says otherwise, so it has to fail startup.
3400        let misplaced = write_toml(
3401            &dir,
3402            r#"
3403[actor]
3404id = "lambda:example"
3405grant_unattributed = false
3406"#,
3407        );
3408        let err = KhiveConfig::load(Some(&misplaced))
3409            .expect_err("a [gate] key written under [actor] must fail startup");
3410        assert!(
3411            err.to_string().contains("grant_unattributed"),
3412            "the refusal must name the offending key, got: {err}"
3413        );
3414    }
3415
3416    #[test]
3417    fn caller_enrollment_policy_is_enforced_at_authorization() {
3418        let dir = tempfile::tempdir().unwrap();
3419        let path = write_toml(
3420            &dir,
3421            r#"
3422[actor]
3423id = "lambda:enrolled"
3424
3425[gate]
3426granted_actors = ["lambda:enrolled"]
3427grant_unattributed = false
3428"#,
3429        );
3430        let mut config = KhiveConfig::load(Some(&path))
3431            .expect("load")
3432            .expect("config exists");
3433        let allowed = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3434        let runtime = crate::KhiveRuntime::new(allowed).expect("runtime");
3435        runtime
3436            .authorize(Namespace::local())
3437            .expect("listed actor is admitted");
3438
3439        config.actor.id = Some("lambda:other".to_string());
3440        let denied = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3441        let runtime = crate::KhiveRuntime::new(denied).expect("runtime");
3442        assert!(matches!(
3443            runtime.authorize(Namespace::local()),
3444            Err(crate::RuntimeError::PermissionDenied { ref verb, ref reason, .. })
3445                if verb == "authorize" && reason == "actor is not enrolled"
3446        ));
3447    }
3448
3449    #[test]
3450    fn grant_unattributed_controls_anonymous_authorization() {
3451        let dir = tempfile::tempdir().unwrap();
3452        let path = write_toml(&dir, "[gate]\ngrant_unattributed = false\n");
3453        let mut config = KhiveConfig::load(Some(&path))
3454            .expect("load")
3455            .expect("config exists");
3456        let denied = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3457        let runtime = crate::KhiveRuntime::new(denied).expect("runtime");
3458        assert!(matches!(
3459            runtime.authorize(Namespace::local()),
3460            Err(crate::RuntimeError::PermissionDenied { ref reason, .. })
3461                if reason == "unattributed caller is not enrolled"
3462        ));
3463
3464        config.gate.as_mut().expect("gate").grant_unattributed = true;
3465        let allowed = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3466        crate::KhiveRuntime::new(allowed)
3467            .expect("runtime")
3468            .authorize(Namespace::local())
3469            .expect("anonymous caller is explicitly admitted");
3470    }
3471
3472    #[test]
3473    fn empty_gate_table_is_explicit_deny_all_policy() {
3474        let dir = tempfile::tempdir().unwrap();
3475        let path = write_toml(&dir, "[gate]\n");
3476        let config = KhiveConfig::load(Some(&path))
3477            .expect("empty gate table parses")
3478            .expect("config exists");
3479        assert_eq!(config.gate, Some(GateSectionConfig::default()));
3480        let runtime_config =
3481            crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3482        let runtime = crate::KhiveRuntime::new(runtime_config).expect("runtime");
3483        assert!(matches!(
3484            runtime.authorize(Namespace::local()),
3485            Err(crate::RuntimeError::PermissionDenied { .. })
3486        ));
3487    }
3488
3489    #[test]
3490    fn unknown_gate_key_fails_startup() {
3491        let dir = tempfile::tempdir().unwrap();
3492        let path = write_toml(&dir, "[gate]\ngranted_actor = [\"lambda:typo\"]\n");
3493        let err = KhiveConfig::load(Some(&path)).expect_err("unknown gate key must fail");
3494        assert!(matches!(err, ConfigError::Parse { .. }));
3495        assert!(err.to_string().contains("unknown field"), "{err}");
3496    }
3497
3498    #[test]
3499    fn write_denials_survive_both_runtime_config_paths() {
3500        let dir = tempfile::tempdir().unwrap();
3501        for engines in [
3502            "",
3503            "\n[[engines]]\nname = 'main'\nmodel = 'all-minilm-l6-v2'\ndefault = true\n",
3504        ] {
3505            let path = write_toml(&dir, &format!(
3506                "[actor]\nid='seat:duty'\n[gate]\ngranted_actors=['seat:duty','seat:writer']\ndeny_writes_for=['*:duty']\n{engines}"
3507            ));
3508            let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3509            let runtime =
3510                crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3511            for (actor, verb, allowed) in [
3512                ("seat:duty", "list", true),
3513                ("seat:duty", "create", false),
3514                ("seat:writer", "create", true),
3515                ("unlisted", "list", false),
3516            ] {
3517                let req = crate::GateRequest::new(
3518                    crate::ActorRef::new("actor", actor),
3519                    Namespace::local(),
3520                    verb,
3521                    serde_json::Value::Null,
3522                );
3523                assert_eq!(
3524                    runtime.gate.check(&req).unwrap().is_allow(),
3525                    allowed,
3526                    "{actor} {verb}"
3527                );
3528            }
3529        }
3530    }
3531
3532    #[test]
3533    fn invalid_write_denials_fail_config_load_and_direct_config_fails_closed() {
3534        let dir = tempfile::tempdir().unwrap();
3535        for value in [
3536            "['']".to_string(),
3537            "['   ']".into(),
3538            format!("['{}']", "é".repeat(129)),
3539            format!("[{}]", vec!["'*'"; 257].join(",")),
3540        ] {
3541            let path = write_toml(&dir, &format!("[gate]\ndeny_writes_for={value}\n"));
3542            let error = KhiveConfig::load(Some(&path)).unwrap_err();
3543            assert!(
3544                matches!(
3545                    config_error_root(&error),
3546                    ConfigError::InvalidWriteDenyPatterns { .. }
3547                ),
3548                "{error}"
3549            );
3550        }
3551        for field in ["deny_write_for=['*']", "deny_writes_for=[17]"] {
3552            let path = write_toml(&dir, &format!("[gate]\n{field}\n"));
3553            assert!(KhiveConfig::load(Some(&path)).is_err());
3554        }
3555        let path = write_toml(
3556            &dir,
3557            "[gate]\ngranted_actors=['writer']\ndeny_writes_for=['用户@*/[?]']\n",
3558        );
3559        let mut config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3560        config.gate.as_mut().unwrap().deny_writes_for = vec![String::new()];
3561        let runtime = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3562        let req = crate::GateRequest::new(
3563            crate::ActorRef::new("actor", "writer"),
3564            Namespace::local(),
3565            "list",
3566            serde_json::Value::Null,
3567        );
3568        assert!(matches!(
3569            runtime.gate.check(&req),
3570            Err(crate::GateError::Policy(_))
3571        ));
3572    }
3573
3574    #[test]
3575    fn absent_gate_preserves_the_programmatic_gate() {
3576        let mut base = in_memory_runtime_config();
3577        base.gate = std::sync::Arc::new(crate::CallerEnrollmentGate::new(vec![], false));
3578        let configured =
3579            crate::runtime_config_from_khive_config(&KhiveConfig::default(), base.clone());
3580        assert!(std::sync::Arc::ptr_eq(&base.gate, &configured.gate));
3581    }
3582
3583    #[test]
3584    fn invalid_granted_actor_fails_startup() {
3585        let dir = tempfile::tempdir().unwrap();
3586        let path = write_toml(&dir, "[gate]\ngranted_actors = [\"not valid\"]\n");
3587        let err = KhiveConfig::load(Some(&path)).expect_err("invalid actor id must fail");
3588        assert!(matches!(
3589            config_error_root(&err),
3590            ConfigError::InvalidGrantedActorId { id, .. } if id == "not valid"
3591        ));
3592    }
3593
3594    #[test]
3595    fn unrelated_unknown_top_level_sections_remain_forward_compatible() {
3596        let dir = tempfile::tempdir().unwrap();
3597        let path = write_toml(&dir, "[future_feature]\nenabled = true\n");
3598        KhiveConfig::load(Some(&path))
3599            .expect("unrelated future config stays forward compatible")
3600            .expect("config exists");
3601    }
3602
3603    #[test]
3604    fn brain_fleet_readers_default_to_empty() {
3605        assert!(KhiveConfig::default().brain.fleet_readers.is_empty());
3606        assert!(in_memory_runtime_config().brain.fleet_readers.is_empty());
3607
3608        let dir = tempfile::tempdir().unwrap();
3609        for engines in [
3610            "",
3611            "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3612        ] {
3613            for brain in ["", "[brain]\n", "[brain]\nfleet_readers = []\n"] {
3614                let path = write_toml(&dir, &format!("{engines}\n{brain}"));
3615                let config = KhiveConfig::load(Some(&path))
3616                    .expect("load")
3617                    .expect("config exists");
3618                assert!(config.brain.fleet_readers.is_empty());
3619
3620                let mut base = in_memory_runtime_config();
3621                base.brain.fleet_readers = vec!["lambda:previous".to_string()];
3622                let resolved = crate::runtime_config_from_khive_config(&config, base);
3623                assert!(resolved.brain.fleet_readers.is_empty());
3624            }
3625        }
3626    }
3627
3628    #[test]
3629    fn brain_fleet_readers_parse_and_resolve_with_or_without_engines() {
3630        let dir = tempfile::tempdir().unwrap();
3631        for engines in [
3632            "",
3633            "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3634        ] {
3635            let path = write_toml(
3636                &dir,
3637                &format!(
3638                    "{engines}\n[brain]\nfleet_readers = [\"lambda:reader\", \"lambda:auditor\"]\n"
3639                ),
3640            );
3641            let config = KhiveConfig::load(Some(&path))
3642                .expect("load")
3643                .expect("config exists");
3644            assert_eq!(
3645                config.brain.fleet_readers,
3646                vec!["lambda:reader", "lambda:auditor"]
3647            );
3648
3649            let mut base = in_memory_runtime_config();
3650            base.brain.fleet_readers = vec!["lambda:previous".to_string()];
3651            let resolved = crate::runtime_config_from_khive_config(&config, base);
3652            assert_eq!(
3653                resolved.brain.fleet_readers,
3654                vec!["lambda:reader", "lambda:auditor"]
3655            );
3656        }
3657    }
3658
3659    #[test]
3660    fn unknown_brain_key_fails_startup() {
3661        let dir = tempfile::tempdir().unwrap();
3662        let path = write_toml(&dir, "[brain]\nfleet_reader = [\"lambda:reader\"]\n");
3663        let err = KhiveConfig::load(Some(&path)).expect_err("unknown brain key must fail");
3664        assert!(matches!(err, ConfigError::Parse { .. }));
3665        assert!(err.to_string().contains("unknown field"), "{err}");
3666    }
3667
3668    #[test]
3669    fn telemetry_missing_default_stays_absent_with_or_without_engines() {
3670        use crate::{TelemetryCarrier, TelemetryConfig};
3671
3672        assert_eq!(KhiveConfig::default().telemetry, TelemetryConfig::default());
3673        assert_eq!(
3674            in_memory_runtime_config().telemetry,
3675            TelemetryConfig::default()
3676        );
3677        let dir = tempfile::tempdir().unwrap();
3678        for engines in [
3679            "",
3680            "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3681        ] {
3682            for telemetry in ["", "[telemetry]\n"] {
3683                let path = write_toml(&dir, &format!("{engines}\n{telemetry}"));
3684                let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3685                let mut base = in_memory_runtime_config();
3686                base.telemetry.stream = "previous".to_string();
3687                base.telemetry.default_carrier = Some(TelemetryCarrier::Durable);
3688                let resolved = crate::runtime_config_from_khive_config(&config, base);
3689                assert_eq!(resolved.telemetry, TelemetryConfig::default());
3690                assert_eq!(resolved.telemetry.stream, "telemetry");
3691                assert_eq!(resolved.telemetry.default_carrier, None);
3692                let error = resolved
3693                    .telemetry
3694                    .validate_activation()
3695                    .expect_err("activating telemetry requires the declared default");
3696                assert!(error.to_string().contains("telemetry.default_carrier"));
3697            }
3698        }
3699    }
3700
3701    #[test]
3702    fn telemetry_table_loads_and_resolves_with_or_without_engines() {
3703        use crate::{TelemetryCarrier, TelemetryFailurePosture};
3704
3705        let dir = tempfile::tempdir().unwrap();
3706        for engines in [
3707            "",
3708            "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3709        ] {
3710            let path = write_toml(
3711                &dir,
3712                &format!(
3713                    r#"{engines}
3714[telemetry]
3715stream = "operations"
3716default_carrier = "durable"
3717[[telemetry.channels]]
3718kinds = ["run.started", "run.completed"]
3719carrier = "durable"
3720failure_posture = "gap"
3721[[telemetry.channels]]
3722kinds = ["turn.delta", "*.heartbeat"]
3723carrier = "ephemeral"
3724failure_posture = "stop"
3725"#
3726                ),
3727            );
3728            let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3729            assert_eq!(config.telemetry.channels.len(), 2);
3730            let resolved =
3731                crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3732            assert_eq!(resolved.telemetry, config.telemetry);
3733            assert_eq!(resolved.telemetry.stream, "operations");
3734            for kind in ["run.started", "run.completed"] {
3735                let policy = resolved.telemetry.policy_for_kind(kind).unwrap();
3736                assert_eq!(policy.carrier, TelemetryCarrier::Durable);
3737                assert_eq!(policy.failure_posture, TelemetryFailurePosture::Gap);
3738            }
3739            for kind in ["turn.delta", "run.heartbeat", "turn.child.heartbeat"] {
3740                let policy = resolved.telemetry.policy_for_kind(kind).unwrap();
3741                assert_eq!(policy.carrier, TelemetryCarrier::Ephemeral);
3742                assert_eq!(policy.failure_posture, TelemetryFailurePosture::Stop);
3743            }
3744            for kind in [
3745                "unclassified",
3746                "heartbeat",
3747                "run.notheartbeat",
3748                "run.heartbeat.extra",
3749            ] {
3750                let policy = resolved.telemetry.policy_for_kind(kind).unwrap();
3751                assert_eq!(policy.carrier, TelemetryCarrier::Durable);
3752                assert_eq!(policy.failure_posture, TelemetryFailurePosture::Stop);
3753            }
3754        }
3755    }
3756
3757    #[test]
3758    fn telemetry_invalid_policy_values_name_the_channel() {
3759        let dir = tempfile::tempdir().unwrap();
3760        for (carrier, posture, field, value) in [
3761            ("disk", "stop", "carrier", "disk"),
3762            ("Durable", "stop", "carrier", "Durable"),
3763            ("durable", "ignore", "failure_posture", "ignore"),
3764            ("durable", "Stop", "failure_posture", "Stop"),
3765        ] {
3766            let path = write_toml(
3767                &dir,
3768                &format!(
3769                    r#"[[telemetry.channels]]
3770kinds = ["first"]
3771carrier = "ephemeral"
3772failure_posture = "gap"
3773[[telemetry.channels]]
3774kinds = ["second"]
3775carrier = "{carrier}"
3776failure_posture = "{posture}"
3777"#
3778                ),
3779            );
3780            let error = KhiveConfig::load(Some(&path)).expect_err("invalid policy must refuse");
3781            let message = error.to_string();
3782            for expected in ["telemetry.channels[1]", field, value] {
3783                assert!(message.contains(expected), "{message}");
3784            }
3785        }
3786        let path = write_toml(&dir, "[telemetry]\ndefault_carrier = \"disk\"\n");
3787        let error = KhiveConfig::load(Some(&path)).expect_err("unknown fallback must refuse");
3788        assert!(
3789            error.to_string().contains("telemetry.default_carrier"),
3790            "{error}"
3791        );
3792    }
3793
3794    #[test]
3795    fn telemetry_overlapping_channels_name_both_entries() {
3796        let dir = tempfile::tempdir().unwrap();
3797        for (first, second) in [
3798            ("run.started", "run.started"),
3799            ("run.heartbeat", "*.heartbeat"),
3800            ("*.heartbeat", "run.heartbeat"),
3801            ("*.heartbeat", "*.heartbeat"),
3802            ("*.heartbeat", "*.child.heartbeat"),
3803            ("*.child.heartbeat", "*.heartbeat"),
3804        ] {
3805            let path = write_toml(
3806                &dir,
3807                &format!(
3808                    r#"[[telemetry.channels]]
3809kinds = ["{first}"]
3810carrier = "ephemeral"
3811failure_posture = "gap"
3812[[telemetry.channels]]
3813kinds = ["{second}"]
3814carrier = "durable"
3815failure_posture = "stop"
3816"#
3817                ),
3818            );
3819            let error = KhiveConfig::load(Some(&path)).expect_err("overlap must refuse");
3820            let message = error.to_string();
3821            for expected in [
3822                "telemetry.channels[1]",
3823                "telemetry.channels[0]",
3824                first,
3825                second,
3826            ] {
3827                assert!(message.contains(expected), "{message}");
3828            }
3829        }
3830    }
3831
3832    #[test]
3833    fn telemetry_empty_and_invalid_kind_patterns_name_the_channel() {
3834        let dir = tempfile::tempdir().unwrap();
3835        for kinds in [
3836            "[]",
3837            "[\"\"]",
3838            "[\" \"]",
3839            "[\"two names\"]",
3840            "[\"*\"]",
3841            "[\"run.*\"]",
3842            "[\"*.\"]",
3843            "[\"**.heartbeat\"]",
3844            "[\"*.heart*beat\"]",
3845        ] {
3846            let path = write_toml(
3847                &dir,
3848                &format!(
3849                    r#"[[telemetry.channels]]
3850kinds = ["first"]
3851carrier = "ephemeral"
3852failure_posture = "gap"
3853[[telemetry.channels]]
3854kinds = {kinds}
3855carrier = "durable"
3856failure_posture = "stop"
3857"#
3858                ),
3859            );
3860            let error = KhiveConfig::load(Some(&path)).expect_err("invalid kinds must refuse");
3861            assert!(
3862                error.to_string().contains("telemetry.channels[1]"),
3863                "{error}"
3864            );
3865        }
3866    }
3867
3868    #[test]
3869    fn telemetry_tables_reject_unknown_keys() {
3870        let dir = tempfile::tempdir().unwrap();
3871        for content in [
3872            "[telemetry]\ndefault_carrrier = \"durable\"\n",
3873            "[telemetry.ring]\ncapacity = 4096\n",
3874            "[[telemetry.channels]]\nkinds = [\"run\"]\ncarrier = \"durable\"\nfailure_posture = \"stop\"\ncarrrier = \"ephemeral\"\n",
3875        ] {
3876            let path = write_toml(&dir, content);
3877            let error = KhiveConfig::load(Some(&path)).expect_err("unknown key must refuse");
3878            assert!(error.to_string().contains("unknown field"), "{error}");
3879        }
3880    }
3881
3882    // ── [git_write] section (ADR-108 Amendment) ─────────────────────────────
3883
3884    // No [git_write] section at all -> empty allowlist, valid config.
3885    #[test]
3886    fn test_no_git_write_section_is_valid_and_empty() {
3887        let dir = tempfile::tempdir().unwrap();
3888        let path = write_toml(&dir, "# no git_write section\n");
3889        let cfg = KhiveConfig::load(Some(&path))
3890            .expect("no error")
3891            .expect("file found");
3892        assert!(cfg.git_write.allowed.is_empty());
3893    }
3894
3895    fn write_git_program_config(dir: &tempfile::TempDir, program: &Path) -> PathBuf {
3896        let program = toml::Value::String(program.to_str().unwrap().to_string());
3897        write_toml(dir, &format!("[git_write]\nprogram = {program}\n"))
3898    }
3899
3900    #[test]
3901    fn git_program_absent_preserves_path_default() {
3902        let dir = tempfile::tempdir().unwrap();
3903        for content in ["# no git_write section\n", "[git_write]\n"] {
3904            let path = write_toml(&dir, content);
3905            let cfg = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3906            assert!(cfg.git_write.program.is_none());
3907            assert_eq!(cfg.git_write.git_program(), Path::new("git"));
3908        }
3909        assert!(GitWriteSectionConfig::default().program.is_none());
3910        assert_eq!(
3911            GitWriteSectionConfig::default().git_program(),
3912            Path::new("git")
3913        );
3914    }
3915
3916    #[cfg(any(unix, windows))]
3917    #[test]
3918    fn git_program_absolute_executable_loads() {
3919        let dir = tempfile::tempdir().unwrap();
3920        let program = std::env::current_exe().unwrap();
3921        let path = write_git_program_config(&dir, &program);
3922        let cfg = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3923        assert_eq!(cfg.git_write.program.as_deref(), Some(program.as_path()));
3924        assert_eq!(cfg.git_write.git_program(), program);
3925    }
3926
3927    #[test]
3928    fn git_program_relative_path_is_rejected_at_load() {
3929        let dir = tempfile::tempdir().unwrap();
3930        for program in ["git", "relative/git"] {
3931            let path = write_git_program_config(&dir, Path::new(program));
3932            let error = KhiveConfig::load(Some(&path)).expect_err("relative program must fail");
3933            assert!(
3934                matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3935                    if key == "git_write.program" && reason == "must be absolute"),
3936                "unexpected error: {error}"
3937            );
3938            assert!(error.to_string().contains("git_write.program"));
3939        }
3940    }
3941
3942    #[test]
3943    fn git_program_missing_file_is_rejected_at_load() {
3944        let dir = tempfile::tempdir().unwrap();
3945        let path = write_git_program_config(&dir, &dir.path().join("missing-git"));
3946        let error = KhiveConfig::load(Some(&path)).expect_err("missing program must fail");
3947        assert!(
3948            matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3949                if key == "git_write.program" && reason == "does not exist"),
3950            "unexpected error: {error}"
3951        );
3952        assert!(error.to_string().contains("git_write.program"));
3953    }
3954
3955    #[test]
3956    fn git_program_nonexecutable_file_is_rejected_at_load() {
3957        let dir = tempfile::tempdir().unwrap();
3958        let program = dir.path().join("git.txt");
3959        std::fs::write(&program, "not executable\n").unwrap();
3960        #[cfg(unix)]
3961        {
3962            use std::os::unix::fs::PermissionsExt;
3963            std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o600)).unwrap();
3964        }
3965        let path = write_git_program_config(&dir, &program);
3966        let error = KhiveConfig::load(Some(&path)).expect_err("nonexecutable program must fail");
3967        assert!(
3968            matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3969                if key == "git_write.program" && reason == "is not executable"),
3970            "unexpected error: {error}"
3971        );
3972        assert!(error.to_string().contains("git_write.program"));
3973    }
3974
3975    #[test]
3976    fn git_program_directory_is_rejected_at_load() {
3977        let dir = tempfile::tempdir().unwrap();
3978        let program = dir.path().join("git.exe");
3979        std::fs::create_dir(&program).unwrap();
3980        #[cfg(unix)]
3981        {
3982            use std::os::unix::fs::PermissionsExt;
3983            std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
3984        }
3985        let path = write_git_program_config(&dir, &program);
3986        let error = KhiveConfig::load(Some(&path)).expect_err("directory program must fail");
3987        assert!(
3988            matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3989                if key == "git_write.program" && reason == "is not executable"),
3990            "unexpected error: {error}"
3991        );
3992        assert!(error.to_string().contains("git_write.program"));
3993    }
3994
3995    // A well-formed [[git_write.allowed]] entry parses correctly.
3996    #[test]
3997    fn test_git_write_entry_parses() {
3998        let dir = tempfile::tempdir().unwrap();
3999        let path = write_toml(
4000            &dir,
4001            r#"
4002[[git_write.allowed]]
4003repo = "/abs/path/repo"
4004branches = ["feat/*", "fix/*"]
4005"#,
4006        );
4007        let cfg = KhiveConfig::load(Some(&path))
4008            .expect("no error")
4009            .expect("file found");
4010        assert_eq!(cfg.git_write.allowed.len(), 1);
4011        assert_eq!(cfg.git_write.allowed[0].repo, "/abs/path/repo");
4012        assert_eq!(
4013            cfg.git_write.allowed[0].branches,
4014            vec!["feat/*".to_string(), "fix/*".to_string()]
4015        );
4016    }
4017
4018    // A relative repo path is rejected at validate() time.
4019    #[test]
4020    fn test_git_write_relative_repo_rejected() {
4021        let dir = tempfile::tempdir().unwrap();
4022        let path = write_toml(
4023            &dir,
4024            r#"
4025[[git_write.allowed]]
4026repo = "relative/path"
4027branches = ["main"]
4028"#,
4029        );
4030        let err = KhiveConfig::load(Some(&path)).expect_err("relative repo must be rejected");
4031        assert!(
4032            matches!(config_error_root(&err), ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "relative/path"),
4033            "expected InvalidGitWriteEntry, got {err:?}"
4034        );
4035    }
4036
4037    // ADR-108: a branch pattern with more than one `*` is rejected at
4038    // validate() time -- the ADR authorizes exact-name or single-wildcard
4039    // patterns only.
4040    #[test]
4041    fn test_git_write_multi_star_branch_pattern_rejected() {
4042        let dir = tempfile::tempdir().unwrap();
4043        let path = write_toml(
4044            &dir,
4045            r#"
4046[[git_write.allowed]]
4047repo = "/abs/path"
4048branches = ["**"]
4049"#,
4050        );
4051        let err = KhiveConfig::load(Some(&path)).expect_err("** must be rejected");
4052        assert!(
4053            matches!(config_error_root(&err), ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
4054            "expected InvalidGitWriteEntry, got {err:?}"
4055        );
4056
4057        let dir2 = tempfile::tempdir().unwrap();
4058        let path2 = write_toml(
4059            &dir2,
4060            r#"
4061[[git_write.allowed]]
4062repo = "/abs/path"
4063branches = ["rel-*-*-final"]
4064"#,
4065        );
4066        let err2 = KhiveConfig::load(Some(&path2)).expect_err("rel-*-*-final must be rejected");
4067        assert!(
4068            matches!(
4069                config_error_root(&err2),
4070                ConfigError::InvalidGitWriteEntry { .. }
4071            ),
4072            "expected InvalidGitWriteEntry, got {err2:?}"
4073        );
4074    }
4075
4076    // Single-wildcard patterns remain accepted.
4077    #[test]
4078    fn test_git_write_single_star_branch_pattern_accepted() {
4079        let dir = tempfile::tempdir().unwrap();
4080        let path = write_toml(
4081            &dir,
4082            r#"
4083[[git_write.allowed]]
4084repo = "/abs/path"
4085branches = ["a*b", "main"]
4086"#,
4087        );
4088        let cfg = KhiveConfig::load(Some(&path))
4089            .expect("no error")
4090            .expect("file found");
4091        assert_eq!(cfg.git_write.allowed[0].branches, vec!["a*b", "main"]);
4092    }
4093
4094    // An entry with an empty branches list is rejected at validate() time --
4095    // it would otherwise silently allowlist a repo for no branch at all.
4096    #[test]
4097    fn test_git_write_empty_branches_rejected() {
4098        let dir = tempfile::tempdir().unwrap();
4099        let path = write_toml(
4100            &dir,
4101            r#"
4102[[git_write.allowed]]
4103repo = "/abs/path"
4104branches = []
4105"#,
4106        );
4107        let err = KhiveConfig::load(Some(&path)).expect_err("empty branches must be rejected");
4108        assert!(
4109            matches!(config_error_root(&err), ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
4110            "expected InvalidGitWriteEntry, got {err:?}"
4111        );
4112    }
4113
4114    #[test]
4115    fn git_actor_mapping_and_resolver_defaults_parse_without_resolution() {
4116        let cfg: KhiveConfig = toml::from_str(
4117            r#"
4118[git_write.actors."lambda:example"]
4119name = "Example"
4120email = "example@example.invalid"
4121credential_ref = "example-reference"
4122platform_identity = "example-login"
4123"#,
4124        )
4125        .unwrap();
4126        if cfg!(unix) {
4127            cfg.validate().unwrap();
4128        } else {
4129            assert!(cfg.validate().is_err());
4130        }
4131        let identity = &cfg.git_write.actors["lambda:example"];
4132        assert_eq!(identity.name, "Example");
4133        assert_eq!(identity.credential_ref, "example-reference");
4134        assert_eq!(
4135            cfg.git_write.credential_resolver,
4136            GitWriteSectionConfig::default().credential_resolver
4137        );
4138    }
4139
4140    #[test]
4141    fn git_resolver_accepts_only_absolute_argv_with_ref_template() {
4142        for argv in [
4143            vec![],
4144            vec!["relative-resolver", "{ref}"],
4145            vec!["/bin/sh", "-c", "{ref}"],
4146            vec!["/usr/bin/env", "sh", "{ref}"],
4147            vec!["/absolute/resolver", "{token}"],
4148            vec!["/absolute/resolver", "--service={ref}"],
4149            vec!["/absolute/resolver"],
4150            vec!["/absolute/resolver", "{ref}", "bad\0arg"],
4151        ] {
4152            let config = GitWriteSectionConfig {
4153                credential_resolver: argv.into_iter().map(str::to_string).collect(),
4154                ..Default::default()
4155            };
4156            assert!(matches!(
4157                config.validate_dev_loop(),
4158                Err(ConfigError::InvalidGitWriteConfig { key, .. }) if key == "credential_resolver"
4159            ));
4160        }
4161        let config = GitWriteSectionConfig {
4162            credential_resolver: vec![
4163                std::env::temp_dir()
4164                    .join("not-installed-yet/resolver")
4165                    .to_string_lossy()
4166                    .into_owned(),
4167                "--reference".to_string(),
4168                "{ref}".to_string(),
4169            ],
4170            ..Default::default()
4171        };
4172        config.validate_dev_loop().unwrap();
4173    }
4174
4175    #[test]
4176    fn git_actor_mapping_rejects_invalid_identity_and_unknown_fields() {
4177        let actor = GitWriteActorConfig {
4178            name: "Example".to_string(),
4179            email: "example@example.invalid".to_string(),
4180            credential_ref: "example-reference".to_string(),
4181            platform_identity: "example-login".to_string(),
4182        };
4183        for field in ["name", "email", "credential_ref", "platform_identity"] {
4184            let mut invalid = actor.clone();
4185            match field {
4186                "name" => invalid.name.clear(),
4187                "email" => invalid.email = "bad\nemail".to_string(),
4188                "credential_ref" => invalid.credential_ref.clear(),
4189                "platform_identity" => invalid.platform_identity.clear(),
4190                _ => unreachable!(),
4191            }
4192            let config = GitWriteSectionConfig {
4193                actors: BTreeMap::from([("example".to_string(), invalid)]),
4194                ..Default::default()
4195            };
4196            assert!(config.validate_dev_loop().is_err());
4197        }
4198        assert!(toml::from_str::<GitWriteActorConfig>(
4199            r#"name = "Example"
4200email = "example@example.invalid"
4201credential_ref = "reference"
4202platform_identity = "login"
4203credential = "not-an-accepted-field""#
4204        )
4205        .is_err());
4206    }
4207
4208    #[test]
4209    fn git_repository_merge_refusals_accept_only_the_two_named_entries() {
4210        let row = |refusals: &[&str]| GitWriteSectionConfig {
4211            repositories: BTreeMap::from([(
4212                "/repo".to_string(),
4213                GitWriteRepositoryConfig {
4214                    remote: "https://github.com/example/repo".to_string(),
4215                    slug: "example/repo".to_string(),
4216                    visibility: "private".to_string(),
4217                    merge_refusals: refusals.iter().map(|entry| entry.to_string()).collect(),
4218                },
4219            )]),
4220            ..Default::default()
4221        };
4222        for refusals in [
4223            &[][..],
4224            &["opener"][..],
4225            &["last_pusher"][..],
4226            &["opener", "last_pusher"][..],
4227        ] {
4228            row(refusals).validate_dev_loop().unwrap();
4229        }
4230        for refusals in [
4231            &["author"][..],
4232            &["Opener"][..],
4233            &["opener", "opener"][..],
4234            &["last_pusher", "opener", "last_pusher"][..],
4235        ] {
4236            assert!(matches!(
4237                row(refusals).validate_dev_loop(),
4238                Err(ConfigError::InvalidGitWriteConfig { key, .. })
4239                    if key == "repositories./repo.merge_refusals"
4240            ));
4241        }
4242        let parsed: GitWriteRepositoryConfig = toml::from_str(
4243            r#"remote = "https://github.com/example/repo"
4244slug = "example/repo"
4245visibility = "private""#,
4246        )
4247        .unwrap();
4248        assert!(parsed.merge_refusals.is_empty());
4249        assert!(toml::from_str::<GitWriteRepositoryConfig>(
4250            r#"remote = "https://github.com/example/repo"
4251slug = "example/repo"
4252visibility = "private"
4253merge_refusal = ["opener"]"#
4254        )
4255        .is_err());
4256    }
4257
4258    #[test]
4259    fn git_contract_faults_are_feature_gated_before_empty_engines_return() {
4260        let cfg = KhiveConfig {
4261            git_write: GitWriteSectionConfig {
4262                contract_faults: true,
4263                ..Default::default()
4264            },
4265            ..Default::default()
4266        };
4267        if cfg!(feature = "contract-faults") {
4268            cfg.validate().unwrap();
4269        } else {
4270            let error = cfg.validate().unwrap_err();
4271            assert!(matches!(error, ConfigError::InvalidGitWriteConfig { .. }));
4272            assert!(error.to_string().contains("contract-faults"));
4273        }
4274    }
4275
4276    #[test]
4277    fn git_unmapped_legacy_default_remains_valid_on_every_platform() {
4278        GitWriteSectionConfig::default()
4279            .validate_dev_loop()
4280            .unwrap();
4281        let config = GitWriteSectionConfig {
4282            credential_resolver: vec!["relative-resolver".to_string(), "{ref}".to_string()],
4283            ..Default::default()
4284        };
4285        assert!(config.validate_dev_loop().is_err());
4286    }
4287
4288    #[test]
4289    fn git_fault_selectors_require_opt_in() {
4290        let config = GitWriteSectionConfig {
4291            fault: Some("git.push:reply-lost-after-effect".to_string()),
4292            ..Default::default()
4293        };
4294        assert!(matches!(
4295            config.validate_dev_loop(),
4296            Err(ConfigError::InvalidGitWriteConfig { key, .. }) if key == "fault"
4297        ));
4298    }
4299
4300    // ── [storage.blob] section (ADR-111 Amendment 2) ─────────────────────────
4301
4302    // No [storage] section at all -> fs default, existing configurations
4303    // keep behaving exactly as they did before this section existed.
4304    #[test]
4305    fn test_no_storage_section_defaults_to_fs() {
4306        let dir = tempfile::tempdir().unwrap();
4307        let path = write_toml(&dir, "# no storage section\n");
4308        let cfg = KhiveConfig::load(Some(&path))
4309            .expect("no error")
4310            .expect("file found");
4311        assert!(cfg.storage.blob.is_none());
4312    }
4313
4314    #[test]
4315    fn test_storage_blob_fs_selection_parses() {
4316        let dir = tempfile::tempdir().unwrap();
4317        let path = write_toml(
4318            &dir,
4319            r#"
4320[storage.blob]
4321backend = "fs"
4322root = "/var/lib/khive/blobs"
4323floor_bytes = 100000000000
4324"#,
4325        );
4326        let cfg = KhiveConfig::load(Some(&path))
4327            .expect("no error")
4328            .expect("file found");
4329        match cfg.storage.blob {
4330            Some(BlobConfig::Fs { root, floor_bytes }) => {
4331                assert_eq!(root.as_deref(), Some("/var/lib/khive/blobs"));
4332                assert_eq!(floor_bytes, Some(100_000_000_000));
4333            }
4334            other => panic!("expected BlobConfig::Fs, got {other:?}"),
4335        }
4336    }
4337
4338    #[test]
4339    fn test_storage_blob_s3_selection_parses() {
4340        let dir = tempfile::tempdir().unwrap();
4341        let path = write_toml(
4342            &dir,
4343            r#"
4344[storage.blob]
4345backend = "s3"
4346bucket = "khive-blobs"
4347region = "us-east-1"
4348endpoint = "https://objects.example.invalid"
4349prefix = "blobs"
4350"#,
4351        );
4352        let cfg = KhiveConfig::load(Some(&path))
4353            .expect("no error")
4354            .expect("file found");
4355        match cfg.storage.blob {
4356            Some(BlobConfig::S3 {
4357                bucket,
4358                region,
4359                endpoint,
4360                prefix,
4361                allow_http,
4362            }) => {
4363                assert_eq!(bucket, "khive-blobs");
4364                assert_eq!(region, "us-east-1");
4365                assert_eq!(endpoint.as_deref(), Some("https://objects.example.invalid"));
4366                assert_eq!(prefix.as_deref(), Some("blobs"));
4367                assert_eq!(allow_http, None);
4368            }
4369            other => panic!("expected BlobConfig::S3, got {other:?}"),
4370        }
4371    }
4372
4373    // An unknown field under [storage.blob] must be a startup error, not
4374    // silently ignored -- unlike the rest of KhiveConfig, this section is
4375    // strict (deny_unknown_fields).
4376    #[test]
4377    fn test_storage_blob_unknown_field_rejected() {
4378        let dir = tempfile::tempdir().unwrap();
4379        let path = write_toml(
4380            &dir,
4381            r#"
4382[storage.blob]
4383backend = "fs"
4384made_up_field = "x"
4385"#,
4386        );
4387        let err = KhiveConfig::load(Some(&path)).expect_err("unknown field must be rejected");
4388        assert!(
4389            matches!(config_error_root(&err), ConfigError::Parse { .. }),
4390            "got {err:?}"
4391        );
4392    }
4393
4394    // An s3-only field (bucket) under backend = "fs" must be rejected: the
4395    // internally tagged enum's Fs variant doesn't declare it, so it is an
4396    // unknown field for that variant.
4397    #[test]
4398    fn test_storage_blob_other_backend_field_rejected() {
4399        let dir = tempfile::tempdir().unwrap();
4400        let path = write_toml(
4401            &dir,
4402            r#"
4403[storage.blob]
4404backend = "fs"
4405bucket = "khive-blobs"
4406"#,
4407        );
4408        let err = KhiveConfig::load(Some(&path)).expect_err("s3 field under fs must be rejected");
4409        assert!(
4410            matches!(config_error_root(&err), ConfigError::Parse { .. }),
4411            "got {err:?}"
4412        );
4413    }
4414
4415    // Credentials are never accepted in TOML (ADR-111 Amendment 2): an
4416    // access-key field under backend = "s3" is unknown to that variant and
4417    // must be rejected, the same way an other-backend field is.
4418    #[test]
4419    fn test_storage_blob_credential_field_rejected() {
4420        let dir = tempfile::tempdir().unwrap();
4421        let path = write_toml(
4422            &dir,
4423            r#"
4424[storage.blob]
4425backend = "s3"
4426bucket = "khive-blobs"
4427region = "us-east-1"
4428access_key_id = "AKIAEXAMPLE"
4429"#,
4430        );
4431        let err = KhiveConfig::load(Some(&path))
4432            .expect_err("a credential field in TOML must be rejected");
4433        assert!(
4434            matches!(config_error_root(&err), ConfigError::Parse { .. }),
4435            "got {err:?}"
4436        );
4437    }
4438
4439    // An unrecognized backend value is rejected by the internally tagged
4440    // enum's own tag matching, same mechanism as an unknown field.
4441    #[test]
4442    fn test_storage_blob_unknown_backend_value_rejected() {
4443        let dir = tempfile::tempdir().unwrap();
4444        let path = write_toml(
4445            &dir,
4446            r#"
4447[storage.blob]
4448backend = "gcs"
4449"#,
4450        );
4451        let err = KhiveConfig::load(Some(&path)).expect_err("unknown backend must be rejected");
4452        assert!(
4453            matches!(config_error_root(&err), ConfigError::Parse { .. }),
4454            "got {err:?}"
4455        );
4456    }
4457
4458    // ── [display] section (ADR-169) ──────────────────────────────────────────
4459
4460    // No [display] section at all -> None, resolved to the host zone downstream.
4461    #[test]
4462    fn test_no_display_section_defaults_to_none() {
4463        let dir = tempfile::tempdir().unwrap();
4464        let path = write_toml(&dir, "# no display section\n");
4465        let cfg = KhiveConfig::load(Some(&path))
4466            .expect("no error")
4467            .expect("file found");
4468        assert!(cfg.display.timezone.is_none());
4469    }
4470
4471    #[test]
4472    fn test_display_timezone_valid_iana_name_parses() {
4473        let dir = tempfile::tempdir().unwrap();
4474        let path = write_toml(
4475            &dir,
4476            r#"
4477[display]
4478timezone = "America/New_York"
4479"#,
4480        );
4481        let cfg = KhiveConfig::load(Some(&path))
4482            .expect("no error")
4483            .expect("file found");
4484        assert_eq!(cfg.display.timezone.as_deref(), Some("America/New_York"));
4485    }
4486
4487    #[test]
4488    fn test_display_timezone_unrecognized_name_rejected() {
4489        let dir = tempfile::tempdir().unwrap();
4490        let path = write_toml(
4491            &dir,
4492            r#"
4493[display]
4494timezone = "Mars/Olympus_Mons"
4495"#,
4496        );
4497        let err = KhiveConfig::load(Some(&path))
4498            .expect_err("an unrecognized IANA zone name must fail at load, not silently fall back");
4499        assert!(
4500            matches!(config_error_root(&err), ConfigError::InvalidDisplayTimezone { ref timezone } if timezone == "Mars/Olympus_Mons"),
4501            "expected InvalidDisplayTimezone, got {err:?}"
4502        );
4503    }
4504
4505    #[test]
4506    fn test_display_timezone_empty_string_rejected() {
4507        let dir = tempfile::tempdir().unwrap();
4508        let path = write_toml(
4509            &dir,
4510            r#"
4511[display]
4512timezone = ""
4513"#,
4514        );
4515        let err =
4516            KhiveConfig::load(Some(&path)).expect_err("an empty timezone string must be rejected");
4517        assert!(
4518            matches!(
4519                config_error_root(&err),
4520                ConfigError::InvalidDisplayTimezone { .. }
4521            ),
4522            "expected InvalidDisplayTimezone, got {err:?}"
4523        );
4524    }
4525    include!("engine_config_backend_batch_tests.rs");
4526}