1use 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#[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 #[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 #[error("{source} (config file: {})", path.display())]
200 InFile {
201 path: PathBuf,
202 #[source]
203 source: Box<ConfigError>,
204 },
205}
206
207impl ConfigError {
208 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#[derive(Debug, Clone, Deserialize)]
227pub struct EngineConfig {
228 pub name: String,
230
231 pub model: String,
236
237 #[serde(default)]
240 pub default: bool,
241
242 pub fusion_weight: Option<f64>,
250
251 pub dims: Option<u32>,
257}
258
259#[derive(Debug, Clone, Deserialize, Default)]
284#[serde(deny_unknown_fields)]
285pub struct ActorConfig {
286 #[serde(default)]
292 pub id: Option<String>,
293
294 #[serde(default)]
297 pub display_name: Option<String>,
298
299 #[serde(default)]
307 pub mailbox_readers: Vec<String>,
308
309 #[serde(default)]
315 pub visible_namespaces: Option<Vec<String>>,
316
317 #[serde(default)]
329 pub allowed_outbound_namespaces: Vec<String>,
330}
331
332#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
339#[serde(deny_unknown_fields)]
340pub struct GateSectionConfig {
341 #[serde(default)]
343 pub granted_actors: Vec<String>,
344
345 #[serde(default)]
347 pub grant_unattributed: bool,
348
349 #[serde(default)]
352 pub deny_writes_for: Vec<String>,
353}
354
355#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
359#[serde(rename_all = "lowercase")]
360pub enum BackendKind {
361 #[default]
363 Sqlite,
364 Memory,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub struct ResolvedWalCeiling {
374 pub configured_bytes: u64,
376 pub effective_bytes: u64,
378 pub source: khive_db::WalCeilingSource,
380}
381
382pub 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#[derive(Debug, Clone, Deserialize)]
465#[serde(deny_unknown_fields)]
466pub struct BackendConfig {
467 pub name: String,
469 #[serde(default)]
471 pub kind: BackendKind,
472 pub path: Option<std::path::PathBuf>,
475 pub cache_mb: Option<u32>,
477 pub journal_mode: Option<String>,
479 #[serde(default)]
485 pub served_kinds: Option<BTreeSet<SubstrateKind>>,
486 #[serde(default)]
488 pub read_only: bool,
489 pub wal_ceiling_bytes: Option<u64>,
493 #[serde(default)]
495 pub disk_reserve_bytes: Option<u64>,
496 #[serde(default)]
498 pub disk_guard_deadline_ms: Option<u64>,
499}
500
501#[derive(Debug, Clone, Deserialize)]
515pub struct PackConfig {
516 pub backend: String,
519 #[serde(default)]
529 pub no_embed: bool,
530}
531
532#[derive(Debug, Clone, Deserialize)]
559#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
560pub enum BlobConfig {
561 Fs {
565 #[serde(default)]
566 root: Option<String>,
567 #[serde(default)]
568 floor_bytes: Option<u64>,
569 },
570 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#[derive(Debug, Clone, Deserialize, Default)]
588#[serde(deny_unknown_fields)]
589pub struct BlobSectionConfig {
590 #[serde(default)]
592 pub file_transfers: bool,
593}
594
595#[derive(Debug, Clone, Deserialize, Default)]
599#[serde(deny_unknown_fields)]
600pub struct StorageSectionConfig {
601 #[serde(default)]
606 pub blob: Option<BlobConfig>,
607}
608
609#[derive(Debug, Clone, Deserialize, Serialize, Default)]
611#[serde(deny_unknown_fields)]
612pub struct BrainSectionConfig {
613 #[serde(default)]
615 pub fleet_readers: Vec<String>,
616}
617
618#[derive(Debug, Clone, Deserialize, Serialize)]
630pub struct GitWriteEntryConfig {
631 pub repo: String,
633 pub branches: Vec<String>,
636}
637
638#[derive(Debug, Clone, Deserialize, Serialize)]
650pub struct GitWriteSectionConfig {
651 #[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 pub remote: String,
673 pub slug: String,
675 pub visibility: String,
676 #[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 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 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#[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#[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 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
979#[serde(deny_unknown_fields)]
980pub struct WebAllowlistEntry {
981 pub host: String,
982}
983
984#[derive(Debug, Clone, Deserialize, Serialize)]
991#[serde(deny_unknown_fields)]
992pub struct WebCredentialConfig {
993 pub name: String,
995 pub env_var: String,
997 pub hosts: Vec<String>,
1000}
1001
1002#[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#[derive(Debug, Clone, Deserialize, Serialize)]
1017#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
1018pub enum WebSearchProviderConfig {
1019 Fixture {
1021 name: String,
1022 #[serde(default)]
1023 default: bool,
1024 results: Vec<WebFixtureResult>,
1025 },
1026 Http {
1033 name: String,
1034 #[serde(default)]
1035 default: bool,
1036 url_template: String,
1037 #[serde(default)]
1038 api_key_env: Option<String>,
1039 #[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#[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 #[serde(default)]
1124 pub read_roots: Vec<String>,
1125}
1126
1127#[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#[derive(Debug, Clone, Deserialize, Default)]
1303pub struct KhiveConfig {
1304 #[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 #[serde(default)]
1320 pub db: Option<String>,
1321
1322 #[serde(default)]
1324 pub engines: Vec<EngineConfig>,
1325
1326 #[serde(default)]
1334 pub actor: ActorConfig,
1335
1336 #[serde(default)]
1340 pub gate: Option<GateSectionConfig>,
1341
1342 #[serde(default)]
1344 pub runtime: RuntimeSectionConfig,
1345
1346 #[serde(default)]
1351 pub backends: Vec<BackendConfig>,
1352
1353 #[serde(default)]
1359 pub packs: std::collections::HashMap<String, PackConfig>,
1360
1361 #[serde(default)]
1363 pub brain: BrainSectionConfig,
1364
1365 #[serde(default)]
1369 pub git_write: GitWriteSectionConfig,
1370
1371 #[serde(default)]
1373 pub blob: BlobSectionConfig,
1374
1375 #[serde(default)]
1378 pub storage: StorageSectionConfig,
1379
1380 #[serde(default)]
1383 pub exec: ExecSectionConfig,
1384
1385 #[serde(default)]
1387 pub telemetry: crate::telemetry_config::TelemetryConfig,
1388
1389 #[serde(default)]
1393 pub display: DisplaySectionConfig,
1394
1395 #[serde(default)]
1400 pub web: WebSectionConfig,
1401}
1402
1403#[derive(Debug, Clone, Deserialize, Default)]
1410pub struct RuntimeSectionConfig {
1411 #[serde(default)]
1414 pub packs: Option<Vec<String>>,
1415
1416 #[serde(default)]
1423 pub brain_profile: Option<String>,
1424
1425 #[serde(default)]
1432 pub default_output_format: Option<OutputFormat>,
1433
1434 #[serde(default)]
1441 pub blob_hydration_bytes: Option<u64>,
1442}
1443
1444#[derive(Debug, Clone, Deserialize, Default)]
1452pub struct DisplaySectionConfig {
1453 #[serde(default)]
1459 pub timezone: Option<String>,
1460}
1461
1462impl KhiveConfig {
1463 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 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 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 pub fn load_with_home_fallback_and_source(
1546 path: Option<&Path>,
1547 db_path: Option<&Path>,
1548 ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1549 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn default_engine(&self) -> Option<&EngineConfig> {
2045 self.engines.iter().find(|e| e.default)
2046 }
2047}
2048
2049pub 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
2085fn 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 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#[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 #[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 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 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 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 #[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 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 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 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 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 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 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 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 #[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 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 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(); 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 #[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 #[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 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 #[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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}