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