solo_storage/config.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! `solo.config.toml` reader/writer.
4//!
5//! The config file lives alongside `solo.db` and stores everything Solo needs
6//! to re-open the database on startup but does NOT need to keep secret. The
7//! Argon2 salt is the load-bearing field — without it, the same passphrase
8//! produces a different key, so the SQLCipher database becomes unreadable.
9//!
10//! Layout (TOML):
11//! ```toml
12//! schema_version = 1
13//! salt_hex = "0123456789abcdef0123456789abcdef" # 16 bytes -> 32 hex
14//!
15//! [embedder]
16//! name = "ollama:nomic-embed-text" # or "stub" for offline dev
17//! version = "v1" # bump on any vector-shifting change
18//! dim = 768 # probed at `solo init` for Ollama
19//! dtype = "f32"
20//! ```
21//!
22//! Why TOML: human-readable for debugging + recovery. The whole file is small;
23//! we don't need a more compact format.
24
25use serde::{Deserialize, Serialize};
26use solo_core::{Error, Result};
27use std::path::Path;
28
29use crate::key_material::SALT_LEN;
30
31/// Current config schema version. Bump on any incompatible field change.
32pub const CONFIG_SCHEMA_VERSION: u32 = 1;
33
34/// Auth-mode config persisted under `[auth]` in `solo.config.toml`
35/// (v0.8.0 P3). Lives in `solo-storage` rather than `solo-api` because
36/// `SoloConfig` is the canonical owner of all on-disk config blocks;
37/// `solo-api::auth::AuthConfig` mirrors this shape and converts at the
38/// transport boundary.
39///
40/// Two modes:
41/// * `bearer` — `[auth] mode = "bearer", token = "…"`
42/// * `oidc` — `[auth] mode = "oidc", discovery_url = "…", audience = "…"`
43/// (optional `tenant_claim_name`, default `solo_tenant`)
44///
45/// Backward compatibility: when `[auth]` is absent, `solo http-serve`
46/// continues to honor `--bearer-token-file` (v0.7.x behavior). Operators
47/// migrate to config-driven auth by writing an `[auth]` block; the flag
48/// stays as a runtime override for ad-hoc deployments.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(tag = "mode", rename_all = "snake_case")]
51pub enum AuthSettings {
52 Bearer {
53 token: String,
54 },
55 Oidc {
56 discovery_url: String,
57 audience: String,
58 #[serde(default = "default_tenant_claim_name")]
59 tenant_claim_name: String,
60 },
61}
62
63fn default_tenant_claim_name() -> String {
64 "solo_tenant".to_string()
65}
66
67/// LLM-backend config persisted under `[llm]` in `solo.config.toml`
68/// (v0.9.0 P0b scaffold; wiring lands in v0.9.0 P1+).
69///
70/// Mirrors the [`AuthSettings`] shape: one enum, `#[serde(tag = "mode",
71/// rename_all = "snake_case")]`, lives in `solo-storage` as the canonical
72/// on-disk owner. The `solo-api`-side runtime mirror (used for transport-
73/// adjacent wiring like the MCP-sampling capability gate) is added in
74/// v0.9.0 P1 alongside the actual `build_llm_client_from_config` builder.
75///
76/// Five modes:
77/// * **`none`** — Steward runs without an LLM. Clustering still
78/// happens; abstractions + contradictions are skipped. Semantically
79/// equivalent to the v0.8.x `NoopLlmClient` path with
80/// `is_real_llm() == false`. Default when no env var hints at a
81/// specific backend (v0.9.0 P1 implements the env-detected default
82/// in `solo init`).
83/// * **`anthropic`** — hosted Anthropic Claude via API key. The
84/// `api_key_env` field names the env var that carries the key (so
85/// the config file itself does NOT contain secrets); `model` selects
86/// the model id used at request time.
87/// * **`openai`** — hosted OpenAI Chat Completions; same env-var shape.
88/// * **`ollama`** — local Ollama daemon at `base_url`; `model` is the
89/// ollama model tag (e.g. `qwen3-coder:30b`).
90/// * **`mcp_sampling`** — the LLM lives on the *connected MCP client*
91/// and is called back via `sampling/createMessage`. v0.9.0 P0b
92/// scaffolds the variant; v0.9.0 P2 wires the actual rmcp-backed
93/// `LlmClient` impl, the capability gate at `mcp.initialize`, and
94/// the daemon-mode validation that refuses-to-start when no MCP
95/// peer is available.
96///
97/// Backward compatibility: when `[llm]` is absent, v0.9.x continues to
98/// honor the v0.8.x env-var precedence (`ANTHROPIC_API_KEY`,
99/// `OPENAI_API_KEY`) emitted with a one-time deprecation warning at
100/// daemon start. v0.10.0 removes the env-var-only path.
101#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(tag = "mode", rename_all = "snake_case")]
103pub enum LlmSettings {
104 /// Cluster-only: the Steward runs but skips every LLM call.
105 /// Default — fresh installs land here when no env-var hint
106 /// surfaces a backend.
107 #[default]
108 None,
109 /// Hosted Anthropic Claude via API key.
110 Anthropic {
111 #[serde(default = "default_anthropic_api_key_env")]
112 api_key_env: String,
113 #[serde(default = "default_anthropic_model")]
114 model: String,
115 },
116 /// Hosted OpenAI Chat Completions via API key.
117 Openai {
118 #[serde(default = "default_openai_api_key_env")]
119 api_key_env: String,
120 #[serde(default = "default_openai_model")]
121 model: String,
122 },
123 /// Local Ollama daemon.
124 Ollama {
125 #[serde(default = "default_ollama_base_url")]
126 base_url: String,
127 #[serde(default = "default_ollama_model")]
128 model: String,
129 },
130 /// MCP-sampling — call back to the connected MCP client. Requires
131 /// a peer that advertises the `sampling` capability at initialize;
132 /// daemon-only deployments (no MCP peer at all) refuse to start
133 /// when this variant is configured. v0.9.0 P2 implements both
134 /// gates; this variant is scaffold-only at P0b.
135 McpSampling,
136}
137
138fn default_anthropic_api_key_env() -> String {
139 "ANTHROPIC_API_KEY".to_string()
140}
141
142fn default_anthropic_model() -> String {
143 "claude-sonnet-4-6".to_string()
144}
145
146fn default_openai_api_key_env() -> String {
147 "OPENAI_API_KEY".to_string()
148}
149
150fn default_openai_model() -> String {
151 "gpt-5o".to_string()
152}
153
154fn default_ollama_base_url() -> String {
155 "http://localhost:11434".to_string()
156}
157
158fn default_ollama_model() -> String {
159 "qwen3-coder:30b".to_string()
160}
161
162impl LlmSettings {
163 /// True iff this variant calls a real LLM backend. `None` and the
164 /// "no peer attached yet" runtime state of `McpSampling` both
165 /// short-circuit, but at config-load time only `None` is statically
166 /// inert.
167 pub fn is_real_llm(&self) -> bool {
168 !matches!(self, LlmSettings::None)
169 }
170
171 /// Canonical TOML `mode` value (matches `#[serde(rename_all =
172 /// "snake_case")]`). Used by error messages so operators see the
173 /// same spelling they wrote in the config file.
174 pub fn mode_str(&self) -> &'static str {
175 match self {
176 LlmSettings::None => "none",
177 LlmSettings::Anthropic { .. } => "anthropic",
178 LlmSettings::Openai { .. } => "openai",
179 LlmSettings::Ollama { .. } => "ollama",
180 LlmSettings::McpSampling => "mcp_sampling",
181 }
182 }
183
184 /// True iff this variant requires a runtime MCP peer to operate.
185 /// Used by daemon-startup validation (v0.9.0 P2: refuses to start
186 /// if the daemon has no MCP-stdio transport configured AND the
187 /// `[llm]` block requests `mcp_sampling`).
188 pub fn requires_mcp_peer(&self) -> bool {
189 matches!(self, LlmSettings::McpSampling)
190 }
191
192 /// Validate the variant against the transport surface available to
193 /// the running process. Returns `Err` when the combination is
194 /// known-invalid at config-load time — today only "mcp_sampling +
195 /// daemon-without-MCP" qualifies. v0.9.0 P2 wires this into the
196 /// daemon-startup path with the full BLOCKER 2 error message
197 /// (commented-out alternative blocks for the other 4 backends).
198 ///
199 /// `mcp_transport_available` is `true` when the running process has
200 /// at least one transport that can host an MCP-sampling session
201 /// (today: stdio MCP only). HTTP-only / pure-daemon callers pass
202 /// `false`.
203 pub fn validate_against_transport(
204 &self,
205 mcp_transport_available: bool,
206 ) -> Result<()> {
207 if self.requires_mcp_peer() && !mcp_transport_available {
208 return Err(Error::storage(
209 "LLM backend `mcp_sampling` requires a connected MCP \
210 client that advertises the `sampling` capability at \
211 initialize. This process is running without an MCP \
212 transport (daemon-only or HTTP-only), so no peer can \
213 be reached. Configure `[llm] mode` to one of \
214 `anthropic`, `openai`, `ollama`, or `none` in \
215 `solo.config.toml`."
216 .to_string(),
217 ));
218 }
219 Ok(())
220 }
221}
222
223/// Cadence + batch knobs for v0.9.0's background triple-extraction
224/// pipeline. Persisted under `[triples]` in `solo.config.toml`.
225///
226/// Note: the v0.9.0 plan §6 sketched this as `[llm.triples]`. P1 lifted
227/// the block to top-level `[triples]` because nesting under `[llm]`
228/// would have required reshaping the v0.9.0 P0b `LlmSettings` enum
229/// (currently `#[serde(tag = "mode")]`) into a `flatten`-style struct,
230/// which would have churned every existing P0b serde test. Lifting
231/// preserves the P0b scaffold unchanged while still exposing the
232/// configuration knobs the plan intended.
233///
234/// Defaults match the plan's MINOR 1 + MINOR 3 revision corrections:
235/// `trigger_interval_secs = 3600` (was 300 pre-MINOR 1, aligned with
236/// `consolidate_interval_secs`); `trigger_episode_count = 50`.
237///
238/// The actual writer.rs `block_on(steward.abstract_cluster)` removal +
239/// daemon-driven Steward batch dispatch is plan §4 P4, gated on P2's
240/// `SamplingLlmClient`. This struct lands the cadence knobs in P1 so
241/// the P4 dispatcher reads its config from the right place when it
242/// arrives.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244pub struct TriplesConfig {
245 /// Time (seconds) between background triple-extraction batches.
246 /// Aligned with `consolidate_interval_secs` per plan MINOR 1
247 /// correction (3600s = hourly).
248 #[serde(default = "default_triples_trigger_interval_secs")]
249 pub trigger_interval_secs: u64,
250 /// Number of new episodes (since last batch) above which the
251 /// extraction batch fires immediately, regardless of the timer.
252 /// Whichever cadence fires first wins.
253 #[serde(default = "default_triples_trigger_episode_count")]
254 pub trigger_episode_count: u32,
255 /// v0.9.0 P1 (plan NEW finding #7): TOML-level default for the
256 /// SWS-equivalent clustering / consolidate cadence. The CLI's
257 /// `--consolidate-interval-secs` flag default is still `0`
258 /// (explicit "I want this off"), but when the operator omits the
259 /// flag entirely, the daemon falls back to this value. Defaults
260 /// to 3600s (hourly), matching MINOR 1's `trigger_interval_secs`
261 /// for consistent user-facing cadence semantics across the two
262 /// timers.
263 #[serde(default = "default_triples_consolidate_interval_secs")]
264 pub consolidate_interval_secs: u64,
265 /// v0.10.1 (P4 audit m5): per-cluster timeout (seconds) applied
266 /// to each `Steward::abstract_cluster` call inside
267 /// `Steward::extract_triples_batch`. A hung LLM backend on one
268 /// cluster no longer blocks subsequent clusters in the same
269 /// batch tick — the timeout fires, the cluster is marked as
270 /// "deferred" (skipped, will retry on the next tick), and the
271 /// next cluster proceeds.
272 ///
273 /// Default 60 (matches `SamplingLlmClient::with_timeout`'s
274 /// recommended ceiling for LLM completions; a coalesced
275 /// per-cluster sampling call inside the writer-actor's batch
276 /// tick should complete well within this).
277 ///
278 /// A value of `0` disables the timeout — every per-cluster call
279 /// runs to natural completion. Useful for operators running on
280 /// very slow local backends (large Ollama models on CPU) who
281 /// would rather wait than defer the cluster. NOT recommended in
282 /// production: a single hung peer can stall the batch
283 /// indefinitely.
284 #[serde(default = "default_triples_cluster_timeout_secs")]
285 pub cluster_timeout_secs: u64,
286}
287
288/// v0.11.1: Steward clustering knobs persisted under `[steward]` in
289/// `solo.config.toml`. Mirrors the runtime [`solo_steward::StewardConfig`]
290/// fields that are tuneable at deploy time without an env-var.
291///
292/// Both fields are `Option<T>`: when omitted (or the whole block is
293/// absent), the runtime falls back to `solo_steward::StewardConfig::default()`
294/// values. This preserves zero-change behaviour for existing configs.
295///
296/// **Layering with env vars**: env vars
297/// (`SOLO_CLUSTER_COSINE_THRESHOLD` + `SOLO_CLUSTER_MIN_SIZE`) WIN over
298/// TOML. The order is `code default ← TOML ← env`, which keeps the
299/// operator's runtime escape hatch alive (set the env var to override
300/// without editing the config file). See
301/// [`solo_steward::StewardConfig::from_settings_then_env`] for the
302/// resolution path.
303///
304/// Why expose only these two knobs (not `abstraction_max_tokens` or
305/// `contradiction_check_enabled`): the v0.11.1 carry-forward issue from
306/// commit `6602386` is specifically about the small-corpus / bundled-
307/// embedder tuning. The other two fields already have stable defaults and
308/// env-var paths; growing the TOML surface for them when no operator has
309/// asked is unwarranted. They remain reachable through their existing
310/// `SOLO_ABSTRACTION_MAX_TOKENS` / `SOLO_CONTRADICTION_CHECK_ENABLED`
311/// env vars.
312#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
313pub struct StewardSettings {
314 /// Minimum cluster size (number of episodes) below which a candidate
315 /// cluster is discarded by the SWS-equivalent clustering pass.
316 /// `None` (block absent or field omitted) → uses
317 /// `StewardConfig::default().cluster_min_size`. Must be `>= 1` if
318 /// present.
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub cluster_min_size: Option<usize>,
321 /// Centroid-cosine threshold used by every
322 /// clustering / existing-merge / merge-candidate count site.
323 /// `None` (block absent or field omitted) → uses
324 /// `StewardConfig::default().cluster_cosine_threshold`. Must be a
325 /// finite f32 in `(0.0, 1.0]` if present.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub cluster_cosine_threshold: Option<f32>,
328}
329
330fn default_triples_trigger_interval_secs() -> u64 {
331 3600
332}
333
334fn default_triples_trigger_episode_count() -> u32 {
335 50
336}
337
338fn default_triples_consolidate_interval_secs() -> u64 {
339 3600
340}
341
342fn default_triples_cluster_timeout_secs() -> u64 {
343 60
344}
345
346impl Default for TriplesConfig {
347 fn default() -> Self {
348 Self {
349 trigger_interval_secs: default_triples_trigger_interval_secs(),
350 trigger_episode_count: default_triples_trigger_episode_count(),
351 consolidate_interval_secs: default_triples_consolidate_interval_secs(),
352 cluster_timeout_secs: default_triples_cluster_timeout_secs(),
353 }
354 }
355}
356
357/// v0.9.0 P4d: coalesce knobs for `SamplingCoordinator` (in
358/// `solo-api::llm::sampling_coordinator`). Persisted under
359/// `[sampling]` in `solo.config.toml`.
360///
361/// Plan §4 P4d names: `coalesce_window_ms` (default 5000) +
362/// `coalesce_max_requests` (default 10). These collapse N
363/// concurrent per-cluster sampling calls (from the
364/// `triples_batch_timer`) into ONE coalesced `peer.create_message`,
365/// surfacing ONE approval prompt per coalesce window in the user's
366/// MCP client instead of N.
367///
368/// Bypass for non-sampling backends: `SamplingCoordinator` is wired
369/// only when `[llm] mode = "mcp_sampling"`. For Ollama / Anthropic /
370/// None, requests pass through to the underlying `LlmClient`
371/// unchanged.
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
373pub struct SamplingConfig {
374 /// Upper bound (in milliseconds) the coordinator waits before
375 /// flushing a non-empty buffer. Plan §4 P4d default: 5000.
376 #[serde(default = "default_sampling_coalesce_window_ms")]
377 pub coalesce_window_ms: u64,
378 /// Buffer size that triggers an immediate flush regardless of
379 /// the window timer. Plan §4 P4d default: 10.
380 #[serde(default = "default_sampling_coalesce_max_requests")]
381 pub coalesce_max_requests: u32,
382}
383
384fn default_sampling_coalesce_window_ms() -> u64 {
385 5000
386}
387
388fn default_sampling_coalesce_max_requests() -> u32 {
389 10
390}
391
392impl Default for SamplingConfig {
393 fn default() -> Self {
394 Self {
395 coalesce_window_ms: default_sampling_coalesce_window_ms(),
396 coalesce_max_requests: default_sampling_coalesce_max_requests(),
397 }
398 }
399}
400
401/// Diagnostic classification for `SamplingConfig` edge values. v0.9.1
402/// P1 Fix 5 (m3): split out from `warn_on_edge_values` so the
403/// classification logic is independently testable without capturing
404/// `tracing` output (the workspace doesn't carry `tracing-test`).
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum SamplingConfigDiagnostic {
407 /// Both bounds are healthy; no operator action needed.
408 Ok,
409 /// One zero bound; coordinator still coalesces via the other
410 /// bound but the resolved behavior is worth logging.
411 Info,
412 /// `coalesce_window_ms == 0` AND `coalesce_max_requests <= 1` —
413 /// coalescing is effectively disabled. Warn the operator.
414 Warn,
415}
416
417impl SamplingConfig {
418 /// v0.9.1 P1 Fix 5 (m3): classify the resolved settings without
419 /// emitting any log line. Pure function; pinned by
420 /// [`tests::sampling_config_diagnostic_classifies_edge_values`].
421 pub fn diagnostic(&self) -> SamplingConfigDiagnostic {
422 if self.coalesce_window_ms == 0 && self.coalesce_max_requests <= 1 {
423 SamplingConfigDiagnostic::Warn
424 } else if self.coalesce_window_ms == 0
425 || self.coalesce_max_requests == 0
426 {
427 SamplingConfigDiagnostic::Info
428 } else {
429 SamplingConfigDiagnostic::Ok
430 }
431 }
432
433 /// v0.9.1 P1 Fix 5 (m3): inspect the resolved settings and emit
434 /// operator-visible warnings for edge values that disable
435 /// coalescing without an outright error.
436 ///
437 /// The coordinator's `with_settings` constructor clamps
438 /// `coalesce_max_requests` via `max_batch.max(1)`, and a zero
439 /// `coalesce_window_ms` makes the buffered-timer flush
440 /// immediately — together they collapse the coordinator to a
441 /// pass-through. That's a legitimate operator choice (e.g. for
442 /// debugging or to surface every approval prompt individually),
443 /// so we don't reject — but it's surprising enough that v0.9.0
444 /// shipped without any signal, which led to the m3 audit finding.
445 ///
446 /// Called from `SoloConfig::read` at startup so the warning lands
447 /// in the daemon log once at process boot.
448 pub fn warn_on_edge_values(&self) {
449 match self.diagnostic() {
450 SamplingConfigDiagnostic::Warn => {
451 tracing::warn!(
452 coalesce_window_ms = self.coalesce_window_ms,
453 coalesce_max_requests = self.coalesce_max_requests,
454 "sampling coalescing disabled by config \
455 (window=0ms, max_requests<=1); each LLM call \
456 goes through the MCP client uncoalesced"
457 );
458 }
459 SamplingConfigDiagnostic::Info => {
460 // One zero, not both — operators may be using `=0` as
461 // a sentinel for "flush by the OTHER bound only". Log
462 // at info so they can confirm the resolved behavior.
463 tracing::info!(
464 coalesce_window_ms = self.coalesce_window_ms,
465 coalesce_max_requests = self.coalesce_max_requests,
466 "sampling config has a zero-valued bound; \
467 resolved settings logged for operator \
468 visibility (coordinator clamps max_requests to \
469 max(1) internally)"
470 );
471 }
472 SamplingConfigDiagnostic::Ok => {}
473 }
474 }
475}
476
477/// Audit log settings persisted under `[audit]` in `solo.config.toml`
478/// (v0.8.0 P4).
479///
480/// `retention_days = None` (omitted block, or block without the field)
481/// = keep audit rows forever. This is the default — compliance use cases
482/// often demand unbounded retention, and Solo treats audit rows as cheap
483/// (~80 bytes/row uncompressed).
484///
485/// `purge_interval_secs = None` = no background sweep. Operators who set
486/// `retention_days` but omit `purge_interval_secs` can still purge
487/// manually via `solo audit purge`. When `Some(N)`, `TenantHandle::open`
488/// spawns a per-tenant tokio task that calls `purge_older_than` every
489/// `N` seconds.
490///
491/// Backward compatible: pre-v0.8.0-P4 configs that omit the block
492/// deserialize as `None` (default = keep forever, no background sweep).
493#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
494pub struct AuditSettings {
495 /// Retain audit rows for this many days. `None` = forever.
496 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub retention_days: Option<u32>,
498 /// Run a background sweep every `purge_interval_secs` seconds in
499 /// every cached `TenantHandle`. `None` (the default) = no background
500 /// sweep. Honored only when `retention_days` is also set; without
501 /// a retention bound a sweep would have nothing to delete.
502 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub purge_interval_secs: Option<u64>,
504}
505
506/// PII redaction settings persisted under `[redaction]` in `solo.config.toml`
507/// (v0.8.0 P5).
508///
509/// Default = `enabled = false` (opt-in per the locked v0.8.0 design).
510/// With `enabled = true` the writer-actor runs every built-in detector
511/// (`email`, `ssn`, `us_phone`, `credit_card`, `aws_access_key`,
512/// `github_pat`) over `episodes.content` and `document_chunks.content`
513/// before INSERT. Operators disable specific defaults via
514/// `exclude_builtin = ["email", ...]`, and add their own under
515/// `[[redaction.custom]]` blocks.
516///
517/// Per-tenant redaction overrides are deliberately NOT supported in
518/// v0.8.0 P5 — the redaction block in `solo.config.toml` is the single
519/// source of truth. Per-tenant config layering is a v0.8.1+ concern.
520///
521/// Backward compatible: pre-v0.8.0-P5 configs that omit the block
522/// deserialize with `RedactionConfig::default()` (everything off).
523#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
524pub struct RedactionConfig {
525 /// Master switch. Default `false` (opt-in).
526 #[serde(default)]
527 pub enabled: bool,
528 /// Names of built-in patterns to disable. Defaults to empty (all
529 /// builtins active when `enabled = true`).
530 #[serde(default)]
531 pub exclude_builtin: Vec<String>,
532 /// Operator-supplied custom patterns. Compiled at
533 /// `RedactionRegistry::from_config` time; an invalid regex here
534 /// surfaces as `TenantHandle::open` error.
535 #[serde(default)]
536 pub custom: Vec<CustomRedactionPattern>,
537}
538
539/// One operator-supplied custom redaction pattern from a
540/// `[[redaction.custom]]` block.
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
542pub struct CustomRedactionPattern {
543 /// Stable identifier — used as the sentinel suffix
544 /// (`[REDACTED:<name>]`) when `replacement` is omitted, and as the
545 /// audit-row count key. Must be non-empty.
546 pub name: String,
547 /// Rust regex syntax. Compiled at registry build time.
548 pub regex: String,
549 /// Optional replacement string. Defaults to `[REDACTED:<name>]`.
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub replacement: Option<String>,
552}
553
554/// Top-level config struct, serialized as TOML to `solo.config.toml`.
555///
556/// v0.11.1: `Eq` was dropped because the embedded [`StewardSettings`]
557/// block carries an `Option<f32>` (cluster cosine threshold). `PartialEq`
558/// stays — the only call site is the `roundtrip_via_disk` config test
559/// which only needs partial equality.
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
561pub struct SoloConfig {
562 /// Version of the config schema itself (NOT the database schema). Bumping
563 /// this lets future Solo versions migrate old config files in-place.
564 pub schema_version: u32,
565 /// 32-character lowercase hex string of the 16-byte Argon2 salt.
566 pub salt_hex: String,
567 /// Embedder identity: name, version, dim, dtype. The database holds
568 /// embeddings tied to a specific `(name, version)`; if those change, the
569 /// daemon refuses to start until `solo reembed` rebuilds them.
570 pub embedder: EmbedderConfig,
571 /// User-identity settings for the read-path. Default empty; backward-
572 /// compatible with configs that don't declare an `[identity]` block.
573 /// Today this carries `user_aliases` so `facts_about` can resolve a
574 /// queried alias against historical triples whose `subject_id` was
575 /// normalised to the canonical `"user"`. v0.5.0 Priority 1, sub-step
576 /// 1C — see `docs/dev-log/0071-v0.5.x-roadmap.md`.
577 #[serde(default)]
578 pub identity: IdentityConfig,
579 /// Document parser + chunker settings for the v0.7.0 RAG memory path.
580 /// Default values match the v0.7.0 plan (target 500 tokens, 50-token
581 /// overlap, the same allow-list as `document::parse::ALLOWED`).
582 /// Backward-compatible: pre-v0.7.0 configs that omit the `[documents]`
583 /// block deserialize cleanly with defaults.
584 #[serde(default)]
585 pub documents: DocumentConfig,
586 /// Auth-mode config for the HTTP transport (v0.8.0 P3). `None` =
587 /// no `[auth]` block in the config file = fall through to the
588 /// v0.7.x `--bearer-token-file` flag (loopback default still
589 /// runs unauthenticated). Operators opt into config-driven auth
590 /// by writing an `[auth]` block.
591 #[serde(default)]
592 pub auth: Option<AuthSettings>,
593 /// Audit log settings (v0.8.0 P4). Default = `AuditSettings::default()`
594 /// (retention_days=None → forever; purge_interval_secs=None → no
595 /// background sweep). The audit table is always created via
596 /// migration 0005 regardless of this config block; the block only
597 /// controls retention behavior.
598 #[serde(default)]
599 pub audit: AuditSettings,
600 /// PII redaction settings (v0.8.0 P5). Default = disabled. When
601 /// enabled, the writer-actor runs the built-in detectors plus any
602 /// `[[redaction.custom]]` patterns over text content before INSERT.
603 /// Telemetry: `redaction.applied` audit rows record pattern-name
604 /// match counts (never the matched substrings — strict).
605 #[serde(default)]
606 pub redaction: RedactionConfig,
607 /// LLM-backend selection (v0.9.0 P0b scaffold; wiring lands in
608 /// v0.9.0 P1). `None` = no `[llm]` block in the config file = fall
609 /// through to the v0.8.x env-var precedence (Anthropic > OpenAI >
610 /// none) with a one-time deprecation warning. Operators opt into
611 /// config-driven LLM selection by writing an `[llm]` block. v0.10.0
612 /// removes the env-var-only path.
613 #[serde(default)]
614 pub llm: Option<LlmSettings>,
615 /// v0.9.0 P1: cadence + batch knobs for background triple
616 /// extraction. Defaults match the plan's MINOR 1 + NEW finding #7
617 /// corrections (`trigger_interval_secs = 3600`,
618 /// `trigger_episode_count = 50`, `consolidate_interval_secs = 3600`).
619 /// Pre-v0.9.0 configs without the `[triples]` block deserialize
620 /// with these defaults — zero behaviour change on the v0.8.x path
621 /// because the CLI's `--consolidate-interval-secs 0` flag default
622 /// still wins when the operator passes it explicitly.
623 #[serde(default)]
624 pub triples: TriplesConfig,
625 /// v0.9.0 P4d: coalesce knobs for the SamplingCoordinator. Default
626 /// = 5000ms window + 10-request max-batch. Operators who hit
627 /// approval-prompt fatigue can tighten the window; operators on
628 /// fast clients can loosen it.
629 ///
630 /// Effectively inert for non-sampling backends (the coordinator
631 /// inserts itself only when `[llm] mode = "mcp_sampling"`).
632 #[serde(default)]
633 pub sampling: SamplingConfig,
634 /// v0.11.1: Steward clustering knobs. Both fields are `Option`;
635 /// `None` (or whole block absent) means "use
636 /// `solo_steward::StewardConfig::default()` for that field". Env
637 /// vars `SOLO_CLUSTER_COSINE_THRESHOLD` + `SOLO_CLUSTER_MIN_SIZE`
638 /// continue to override per-runtime.
639 ///
640 /// See [`StewardSettings`] for the rationale (the v0.11.0 carry-
641 /// forward from commit `6602386` flagging the inline TODO for
642 /// exposing both fields as TOML config).
643 #[serde(default)]
644 pub steward: StewardSettings,
645}
646
647/// User-identity settings persisted under `[identity]` in `solo.config.toml`.
648///
649/// `user_aliases` lets a user query `facts_about(subject = "alex")` and have
650/// the read path also surface rows that were extracted historically with the
651/// canonical `subject_id = "user"` (or vice-versa). The forward-going
652/// extraction pipeline (Priority 1 sub-steps 1A + 1B) prefers named entities
653/// over `"user"`, but historical triples written before 1A still use
654/// `"user"` — read-side alias expansion bridges the two without rewriting
655/// any data.
656///
657/// Default = empty — zero behaviour change for existing configs.
658#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
659pub struct IdentityConfig {
660 /// Names that should be treated as equivalent to the canonical `"user"`
661 /// subject when querying `facts_about`. Lets a user query "facts about
662 /// alex" and get rows that were historically extracted with
663 /// `subject_id = "user"`. Case-sensitive — match the casing in the
664 /// triples table.
665 #[serde(default)]
666 pub user_aliases: Vec<String>,
667}
668
669/// Document parser + chunker settings persisted under `[documents]` in
670/// `solo.config.toml`. New in v0.7.0 (RAG / document-memory).
671///
672/// Defaults match the v0.7.0 implementation plan:
673/// * `chunk_token_target = 500` — approx 2000 chars per chunk
674/// * `chunk_overlap_tokens = 50` — ~10% overlap so cross-boundary
675/// sentences survive into both neighbouring chunks
676/// * `allowed_extensions` — see `document::parse::ALLOWED` for the
677/// canonical list (kept in sync; this field exists so operators
678/// can disable specific formats without recompiling).
679///
680/// Backward compatible: pre-v0.7.0 configs that omit the block
681/// deserialize with all defaults applied.
682#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
683pub struct DocumentConfig {
684 #[serde(default = "default_chunk_token_target")]
685 pub chunk_token_target: u32,
686 #[serde(default = "default_chunk_overlap_tokens")]
687 pub chunk_overlap_tokens: u32,
688 #[serde(default = "default_allowed_extensions")]
689 pub allowed_extensions: Vec<String>,
690}
691
692fn default_chunk_token_target() -> u32 {
693 500
694}
695
696fn default_chunk_overlap_tokens() -> u32 {
697 50
698}
699
700fn default_allowed_extensions() -> Vec<String> {
701 vec![
702 "md", "markdown", "txt", "rs", "py", "toml", "yaml", "yml", "json", "pdf", "html", "htm",
703 ]
704 .into_iter()
705 .map(String::from)
706 .collect()
707}
708
709impl Default for DocumentConfig {
710 fn default() -> Self {
711 Self {
712 chunk_token_target: default_chunk_token_target(),
713 chunk_overlap_tokens: default_chunk_overlap_tokens(),
714 allowed_extensions: default_allowed_extensions(),
715 }
716 }
717}
718
719/// Embedder identity persisted to disk so startup can detect drift.
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
721pub struct EmbedderConfig {
722 pub name: String,
723 pub version: String,
724 pub dim: u32,
725 /// Serialized form of `solo_core::EmbeddingDtype`: "f32" | "f16" | "i8" | "binary".
726 pub dtype: String,
727}
728
729impl SoloConfig {
730 /// Build a fresh config for first-run setup. Caller supplies the salt
731 /// (typically `KeyMaterial::fresh_salt()`). `identity` defaults to
732 /// empty — `solo init` does not seed `user_aliases`; users opt in by
733 /// editing `solo.config.toml`.
734 pub fn new(salt: [u8; SALT_LEN], embedder: EmbedderConfig) -> Self {
735 Self {
736 schema_version: CONFIG_SCHEMA_VERSION,
737 salt_hex: hex::encode(salt),
738 embedder,
739 identity: IdentityConfig::default(),
740 documents: DocumentConfig::default(),
741 auth: None,
742 audit: AuditSettings::default(),
743 redaction: RedactionConfig::default(),
744 llm: None,
745 triples: TriplesConfig::default(),
746 sampling: SamplingConfig::default(),
747 steward: StewardSettings::default(),
748 }
749 }
750
751 /// Decode the persisted salt back to its 16-byte form.
752 pub fn salt_bytes(&self) -> Result<[u8; SALT_LEN]> {
753 let bytes = hex::decode(&self.salt_hex)
754 .map_err(|e| Error::storage(format!("config salt_hex is not valid hex: {e}")))?;
755 if bytes.len() != SALT_LEN {
756 return Err(Error::storage(format!(
757 "config salt_hex must decode to {} bytes, got {}",
758 SALT_LEN,
759 bytes.len()
760 )));
761 }
762 let mut out = [0u8; SALT_LEN];
763 out.copy_from_slice(&bytes);
764 Ok(out)
765 }
766
767 /// Serialize to `solo.config.toml` at the given path. Atomic-writes via a
768 /// `<path>.tmp` file + rename so a crash mid-write can't leave a partial
769 /// config. Refuses to overwrite an existing file (caller must handle the
770 /// already-initialized case).
771 ///
772 /// Durability ordering: write tmp → fsync tmp → rename → fsync parent dir
773 /// (Unix only; Windows relies on NTFS's metadata journal). The salt
774 /// stored here is the only path back into the SQLCipher database — a
775 /// partial-write corruption locks the user out forever, so we pay the
776 /// fsync cost (~1 ms) without compromise.
777 pub fn write(&self, path: &Path) -> Result<()> {
778 if path.exists() {
779 return Err(Error::conflict(format!(
780 "config already exists: {}",
781 path.display()
782 )));
783 }
784 let tmp_path = path.with_extension("toml.tmp");
785 let body = toml::to_string_pretty(self)
786 .map_err(|e| Error::storage(format!("toml serialize: {e}")))?;
787
788 // Open + write + fsync the tmp file before exposing it via rename.
789 {
790 let mut tmp_file = std::fs::OpenOptions::new()
791 .write(true)
792 .create_new(true)
793 .open(&tmp_path)
794 .map_err(|e| Error::storage(format!("open tmp {}: {e}", tmp_path.display())))?;
795 std::io::Write::write_all(&mut tmp_file, body.as_bytes())
796 .map_err(|e| Error::storage(format!("write {}: {e}", tmp_path.display())))?;
797 tmp_file
798 .sync_all()
799 .map_err(|e| Error::storage(format!("fsync tmp {}: {e}", tmp_path.display())))?;
800 }
801
802 std::fs::rename(&tmp_path, path)
803 .map_err(|e| Error::storage(format!("rename to {}: {e}", path.display())))?;
804
805 // fsync the parent directory so the rename persists across a crash.
806 // No-op on Windows — opening a directory for FlushFileBuffers requires
807 // FILE_FLAG_BACKUP_SEMANTICS; NTFS's metadata journal handles this case.
808 #[cfg(unix)]
809 {
810 if let Some(parent) = path.parent() {
811 if let Ok(d) = std::fs::OpenOptions::new().read(true).open(parent) {
812 let _ = d.sync_all();
813 }
814 }
815 }
816
817 Ok(())
818 }
819
820 /// Read + parse from `solo.config.toml`. Validates schema_version.
821 pub fn read(path: &Path) -> Result<Self> {
822 let body = std::fs::read_to_string(path)
823 .map_err(|e| Error::storage(format!("read {}: {e}", path.display())))?;
824 let cfg: Self = toml::from_str(&body)
825 .map_err(|e| Error::storage(format!("toml parse {}: {e}", path.display())))?;
826 if cfg.schema_version != CONFIG_SCHEMA_VERSION {
827 return Err(Error::storage(format!(
828 "config schema_version mismatch: file is v{}, this binary expects v{}",
829 cfg.schema_version, CONFIG_SCHEMA_VERSION
830 )));
831 }
832 // Validate salt_hex shape eagerly so callers see the error here, not
833 // later at key-derive time.
834 let _ = cfg.salt_bytes()?;
835 // v0.9.1 P1 Fix 5 (m3): surface SamplingConfig edge values to
836 // the operator log so a `coalesce_window_ms = 0,
837 // coalesce_max_requests = 0` config doesn't silently disable
838 // coalescing.
839 cfg.sampling.warn_on_edge_values();
840 Ok(cfg)
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847 use tempfile::TempDir;
848
849 fn fixture_embedder() -> EmbedderConfig {
850 EmbedderConfig {
851 name: "bge-m3".into(),
852 version: "v1.0".into(),
853 dim: 1024,
854 dtype: "f32".into(),
855 }
856 }
857
858 #[test]
859 fn roundtrip_via_disk() {
860 let tmp = TempDir::new().unwrap();
861 let path = tmp.path().join("solo.config.toml");
862
863 let salt = [7u8; SALT_LEN];
864 let cfg = SoloConfig::new(salt, fixture_embedder());
865 cfg.write(&path).unwrap();
866
867 let read_back = SoloConfig::read(&path).unwrap();
868 assert_eq!(cfg, read_back);
869 assert_eq!(read_back.salt_bytes().unwrap(), salt);
870 }
871
872 #[test]
873 fn write_refuses_overwrite() {
874 let tmp = TempDir::new().unwrap();
875 let path = tmp.path().join("solo.config.toml");
876 let cfg = SoloConfig::new([0; SALT_LEN], fixture_embedder());
877 cfg.write(&path).unwrap();
878 let err = cfg.write(&path).unwrap_err();
879 assert!(err.to_string().contains("already exists"), "got: {err}");
880 }
881
882 #[test]
883 fn read_rejects_wrong_schema_version() {
884 let tmp = TempDir::new().unwrap();
885 let path = tmp.path().join("solo.config.toml");
886 std::fs::write(
887 &path,
888 r#"
889schema_version = 99
890salt_hex = "00000000000000000000000000000000"
891
892[embedder]
893name = "bge-m3"
894version = "v1.0"
895dim = 1024
896dtype = "f32"
897"#,
898 )
899 .unwrap();
900 let err = SoloConfig::read(&path).unwrap_err();
901 assert!(err.to_string().contains("schema_version mismatch"), "got: {err}");
902 }
903
904 #[test]
905 fn read_rejects_non_hex_salt() {
906 let tmp = TempDir::new().unwrap();
907 let path = tmp.path().join("solo.config.toml");
908 std::fs::write(
909 &path,
910 format!(
911 r#"
912schema_version = {CONFIG_SCHEMA_VERSION}
913salt_hex = "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"
914
915[embedder]
916name = "bge-m3"
917version = "v1.0"
918dim = 1024
919dtype = "f32"
920"#
921 ),
922 )
923 .unwrap();
924 let err = SoloConfig::read(&path).unwrap_err();
925 // hex::decode fails on non-hex chars → "not valid hex".
926 assert!(err.to_string().contains("salt_hex"), "got: {err}");
927 }
928
929 #[test]
930 fn read_rejects_missing_embedder_block() {
931 let tmp = TempDir::new().unwrap();
932 let path = tmp.path().join("solo.config.toml");
933 std::fs::write(
934 &path,
935 format!(
936 r#"
937schema_version = {CONFIG_SCHEMA_VERSION}
938salt_hex = "00000000000000000000000000000000"
939"#
940 ),
941 )
942 .unwrap();
943 let err = SoloConfig::read(&path).unwrap_err();
944 // serde error for missing field
945 assert!(err.to_string().to_lowercase().contains("embedder") || err.to_string().contains("missing"), "got: {err}");
946 }
947
948 #[test]
949 fn read_loads_user_aliases_from_identity_block() {
950 let tmp = TempDir::new().unwrap();
951 let path = tmp.path().join("solo.config.toml");
952 std::fs::write(
953 &path,
954 format!(
955 r#"
956schema_version = {CONFIG_SCHEMA_VERSION}
957salt_hex = "00000000000000000000000000000000"
958
959[embedder]
960name = "bge-m3"
961version = "v1.0"
962dim = 1024
963dtype = "f32"
964
965[identity]
966user_aliases = ["alex", "alice"]
967"#
968 ),
969 )
970 .unwrap();
971 let cfg = SoloConfig::read(&path).expect("read ok");
972 assert_eq!(cfg.identity.user_aliases, vec!["alex".to_string(), "alice".to_string()]);
973 }
974
975 #[test]
976 fn read_defaults_identity_when_block_absent() {
977 // Backward compat: existing configs (pre-v0.5.0) have no
978 // [identity] block. They must still deserialize cleanly, with
979 // `user_aliases` defaulting to empty.
980 let tmp = TempDir::new().unwrap();
981 let path = tmp.path().join("solo.config.toml");
982 std::fs::write(
983 &path,
984 format!(
985 r#"
986schema_version = {CONFIG_SCHEMA_VERSION}
987salt_hex = "00000000000000000000000000000000"
988
989[embedder]
990name = "bge-m3"
991version = "v1.0"
992dim = 1024
993dtype = "f32"
994"#
995 ),
996 )
997 .unwrap();
998 let cfg = SoloConfig::read(&path).expect("read ok");
999 assert!(cfg.identity.user_aliases.is_empty());
1000 }
1001
1002 #[test]
1003 fn read_defaults_user_aliases_when_identity_block_empty() {
1004 let tmp = TempDir::new().unwrap();
1005 let path = tmp.path().join("solo.config.toml");
1006 std::fs::write(
1007 &path,
1008 format!(
1009 r#"
1010schema_version = {CONFIG_SCHEMA_VERSION}
1011salt_hex = "00000000000000000000000000000000"
1012
1013[embedder]
1014name = "bge-m3"
1015version = "v1.0"
1016dim = 1024
1017dtype = "f32"
1018
1019[identity]
1020"#
1021 ),
1022 )
1023 .unwrap();
1024 let cfg = SoloConfig::read(&path).expect("read ok");
1025 assert!(cfg.identity.user_aliases.is_empty());
1026 }
1027
1028 #[test]
1029 fn read_defaults_documents_when_block_absent() {
1030 // Pre-v0.7.0 configs have no [documents] block. They must still
1031 // deserialize cleanly, with defaults applied.
1032 let tmp = TempDir::new().unwrap();
1033 let path = tmp.path().join("solo.config.toml");
1034 std::fs::write(
1035 &path,
1036 format!(
1037 r#"
1038schema_version = {CONFIG_SCHEMA_VERSION}
1039salt_hex = "00000000000000000000000000000000"
1040
1041[embedder]
1042name = "bge-m3"
1043version = "v1.0"
1044dim = 1024
1045dtype = "f32"
1046"#
1047 ),
1048 )
1049 .unwrap();
1050 let cfg = SoloConfig::read(&path).expect("read ok");
1051 assert_eq!(cfg.documents.chunk_token_target, 500);
1052 assert_eq!(cfg.documents.chunk_overlap_tokens, 50);
1053 assert!(cfg.documents.allowed_extensions.contains(&"md".to_string()));
1054 assert!(cfg.documents.allowed_extensions.contains(&"pdf".to_string()));
1055 }
1056
1057 #[test]
1058 fn read_loads_custom_documents_block() {
1059 let tmp = TempDir::new().unwrap();
1060 let path = tmp.path().join("solo.config.toml");
1061 std::fs::write(
1062 &path,
1063 format!(
1064 r#"
1065schema_version = {CONFIG_SCHEMA_VERSION}
1066salt_hex = "00000000000000000000000000000000"
1067
1068[embedder]
1069name = "bge-m3"
1070version = "v1.0"
1071dim = 1024
1072dtype = "f32"
1073
1074[documents]
1075chunk_token_target = 250
1076chunk_overlap_tokens = 25
1077allowed_extensions = ["md", "txt"]
1078"#
1079 ),
1080 )
1081 .unwrap();
1082 let cfg = SoloConfig::read(&path).expect("read ok");
1083 assert_eq!(cfg.documents.chunk_token_target, 250);
1084 assert_eq!(cfg.documents.chunk_overlap_tokens, 25);
1085 assert_eq!(cfg.documents.allowed_extensions, vec!["md".to_string(), "txt".to_string()]);
1086 }
1087
1088 #[test]
1089 fn document_config_default_matches_plan() {
1090 let d = DocumentConfig::default();
1091 assert_eq!(d.chunk_token_target, 500);
1092 assert_eq!(d.chunk_overlap_tokens, 50);
1093 // Sanity: the allow-list mirrors the parser's. If parse::ALLOWED
1094 // grows, this default + the test below should be kept in sync.
1095 for ext in &["md", "markdown", "txt", "rs", "py", "toml", "yaml", "yml", "json", "pdf", "html", "htm"] {
1096 assert!(
1097 d.allowed_extensions.iter().any(|e| e == ext),
1098 "default allowed_extensions missing {ext}"
1099 );
1100 }
1101 }
1102
1103 #[test]
1104 fn read_defaults_auth_when_block_absent() {
1105 // Pre-v0.8.0 configs (or operators sticking with the
1106 // `--bearer-token-file` CLI flag) have no `[auth]` block.
1107 // They must deserialize cleanly with `auth = None`.
1108 let tmp = TempDir::new().unwrap();
1109 let path = tmp.path().join("solo.config.toml");
1110 std::fs::write(
1111 &path,
1112 format!(
1113 r#"
1114schema_version = {CONFIG_SCHEMA_VERSION}
1115salt_hex = "00000000000000000000000000000000"
1116
1117[embedder]
1118name = "bge-m3"
1119version = "v1.0"
1120dim = 1024
1121dtype = "f32"
1122"#
1123 ),
1124 )
1125 .unwrap();
1126 let cfg = SoloConfig::read(&path).expect("read ok");
1127 assert!(cfg.auth.is_none());
1128 }
1129
1130 #[test]
1131 fn read_loads_bearer_auth_block() {
1132 let tmp = TempDir::new().unwrap();
1133 let path = tmp.path().join("solo.config.toml");
1134 std::fs::write(
1135 &path,
1136 format!(
1137 r#"
1138schema_version = {CONFIG_SCHEMA_VERSION}
1139salt_hex = "00000000000000000000000000000000"
1140
1141[embedder]
1142name = "bge-m3"
1143version = "v1.0"
1144dim = 1024
1145dtype = "f32"
1146
1147[auth]
1148mode = "bearer"
1149token = "s3cr3t"
1150"#
1151 ),
1152 )
1153 .unwrap();
1154 let cfg = SoloConfig::read(&path).expect("read ok");
1155 match cfg.auth {
1156 Some(AuthSettings::Bearer { token }) => assert_eq!(token, "s3cr3t"),
1157 other => panic!("expected bearer, got {other:?}"),
1158 }
1159 }
1160
1161 #[test]
1162 fn read_loads_oidc_auth_block_with_default_tenant_claim() {
1163 let tmp = TempDir::new().unwrap();
1164 let path = tmp.path().join("solo.config.toml");
1165 std::fs::write(
1166 &path,
1167 format!(
1168 r#"
1169schema_version = {CONFIG_SCHEMA_VERSION}
1170salt_hex = "00000000000000000000000000000000"
1171
1172[embedder]
1173name = "bge-m3"
1174version = "v1.0"
1175dim = 1024
1176dtype = "f32"
1177
1178[auth]
1179mode = "oidc"
1180discovery_url = "https://idp.example.com/.well-known/openid-configuration"
1181audience = "solo-prod"
1182"#
1183 ),
1184 )
1185 .unwrap();
1186 let cfg = SoloConfig::read(&path).expect("read ok");
1187 match cfg.auth {
1188 Some(AuthSettings::Oidc {
1189 discovery_url,
1190 audience,
1191 tenant_claim_name,
1192 }) => {
1193 assert_eq!(
1194 discovery_url,
1195 "https://idp.example.com/.well-known/openid-configuration"
1196 );
1197 assert_eq!(audience, "solo-prod");
1198 // default
1199 assert_eq!(tenant_claim_name, "solo_tenant");
1200 }
1201 other => panic!("expected oidc, got {other:?}"),
1202 }
1203 }
1204
1205 #[test]
1206 fn read_loads_oidc_auth_block_with_custom_tenant_claim() {
1207 let tmp = TempDir::new().unwrap();
1208 let path = tmp.path().join("solo.config.toml");
1209 std::fs::write(
1210 &path,
1211 format!(
1212 r#"
1213schema_version = {CONFIG_SCHEMA_VERSION}
1214salt_hex = "00000000000000000000000000000000"
1215
1216[embedder]
1217name = "bge-m3"
1218version = "v1.0"
1219dim = 1024
1220dtype = "f32"
1221
1222[auth]
1223mode = "oidc"
1224discovery_url = "https://idp.example.com/.well-known/openid-configuration"
1225audience = "solo-prod"
1226tenant_claim_name = "org_id"
1227"#
1228 ),
1229 )
1230 .unwrap();
1231 let cfg = SoloConfig::read(&path).expect("read ok");
1232 match cfg.auth {
1233 Some(AuthSettings::Oidc {
1234 tenant_claim_name, ..
1235 }) => assert_eq!(tenant_claim_name, "org_id"),
1236 other => panic!("expected oidc, got {other:?}"),
1237 }
1238 }
1239
1240 #[test]
1241 fn read_defaults_audit_when_block_absent() {
1242 // Pre-v0.8.0-P4 configs (and the typical fresh init) have no
1243 // `[audit]` block. They must deserialize cleanly with default
1244 // AuditSettings (retention_days=None, purge_interval_secs=None).
1245 let tmp = TempDir::new().unwrap();
1246 let path = tmp.path().join("solo.config.toml");
1247 std::fs::write(
1248 &path,
1249 format!(
1250 r#"
1251schema_version = {CONFIG_SCHEMA_VERSION}
1252salt_hex = "00000000000000000000000000000000"
1253
1254[embedder]
1255name = "bge-m3"
1256version = "v1.0"
1257dim = 1024
1258dtype = "f32"
1259"#
1260 ),
1261 )
1262 .unwrap();
1263 let cfg = SoloConfig::read(&path).expect("read ok");
1264 assert!(cfg.audit.retention_days.is_none());
1265 assert!(cfg.audit.purge_interval_secs.is_none());
1266 }
1267
1268 #[test]
1269 fn read_loads_audit_block_with_retention_only() {
1270 let tmp = TempDir::new().unwrap();
1271 let path = tmp.path().join("solo.config.toml");
1272 std::fs::write(
1273 &path,
1274 format!(
1275 r#"
1276schema_version = {CONFIG_SCHEMA_VERSION}
1277salt_hex = "00000000000000000000000000000000"
1278
1279[embedder]
1280name = "bge-m3"
1281version = "v1.0"
1282dim = 1024
1283dtype = "f32"
1284
1285[audit]
1286retention_days = 30
1287"#
1288 ),
1289 )
1290 .unwrap();
1291 let cfg = SoloConfig::read(&path).expect("read ok");
1292 assert_eq!(cfg.audit.retention_days, Some(30));
1293 assert!(cfg.audit.purge_interval_secs.is_none());
1294 }
1295
1296 #[test]
1297 fn read_loads_audit_block_with_purge_interval() {
1298 let tmp = TempDir::new().unwrap();
1299 let path = tmp.path().join("solo.config.toml");
1300 std::fs::write(
1301 &path,
1302 format!(
1303 r#"
1304schema_version = {CONFIG_SCHEMA_VERSION}
1305salt_hex = "00000000000000000000000000000000"
1306
1307[embedder]
1308name = "bge-m3"
1309version = "v1.0"
1310dim = 1024
1311dtype = "f32"
1312
1313[audit]
1314retention_days = 7
1315purge_interval_secs = 3600
1316"#
1317 ),
1318 )
1319 .unwrap();
1320 let cfg = SoloConfig::read(&path).expect("read ok");
1321 assert_eq!(cfg.audit.retention_days, Some(7));
1322 assert_eq!(cfg.audit.purge_interval_secs, Some(3600));
1323 }
1324
1325 #[test]
1326 fn read_rejects_short_salt_hex() {
1327 let tmp = TempDir::new().unwrap();
1328 let path = tmp.path().join("solo.config.toml");
1329 std::fs::write(
1330 &path,
1331 format!(
1332 r#"
1333schema_version = {CONFIG_SCHEMA_VERSION}
1334salt_hex = "deadbeef"
1335
1336[embedder]
1337name = "bge-m3"
1338version = "v1.0"
1339dim = 1024
1340dtype = "f32"
1341"#
1342 ),
1343 )
1344 .unwrap();
1345 let err = SoloConfig::read(&path).unwrap_err();
1346 assert!(err.to_string().contains("salt_hex"), "got: {err}");
1347 }
1348
1349 // ----------------------------------------------------------------
1350 // v0.9.0 P0b — LlmSettings enum scaffold tests
1351 // ----------------------------------------------------------------
1352 //
1353 // The enum is the on-disk shape for the `[llm]` block in
1354 // `solo.config.toml`. v0.9.0 P1 wires the builder
1355 // (`build_llm_client_from_config`); P2 wires the MCP-sampling
1356 // capability gate. These tests cover *only* the serde + variant
1357 // semantics so the scaffold lands stable.
1358
1359 /// Anthropic-mode round-trip: TOML → enum → TOML with custom + default
1360 /// fields, verifying field-level deserialization works and the model
1361 /// + env-var defaults activate when fields are omitted.
1362 #[test]
1363 fn llm_settings_anthropic_round_trip_with_defaults() {
1364 let toml_in = r#"mode = "anthropic""#;
1365 let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
1366 match parsed {
1367 LlmSettings::Anthropic {
1368 ref api_key_env,
1369 ref model,
1370 } => {
1371 assert_eq!(api_key_env, "ANTHROPIC_API_KEY");
1372 assert_eq!(model, "claude-sonnet-4-6");
1373 }
1374 other => panic!("expected Anthropic, got {other:?}"),
1375 }
1376 let serialized = toml::to_string(&parsed).expect("serialize");
1377 // round-trip stability: re-parse what we serialized.
1378 let reparsed: LlmSettings = toml::from_str(&serialized).expect("reparse");
1379 assert_eq!(parsed, reparsed);
1380 }
1381
1382 #[test]
1383 fn llm_settings_openai_with_custom_model_and_env() {
1384 let toml_in = r#"
1385mode = "openai"
1386api_key_env = "MY_OAI_KEY"
1387model = "gpt-5o-mini"
1388"#;
1389 let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
1390 assert_eq!(
1391 parsed,
1392 LlmSettings::Openai {
1393 api_key_env: "MY_OAI_KEY".into(),
1394 model: "gpt-5o-mini".into(),
1395 }
1396 );
1397 assert_eq!(parsed.mode_str(), "openai");
1398 assert!(parsed.is_real_llm());
1399 assert!(!parsed.requires_mcp_peer());
1400 }
1401
1402 #[test]
1403 fn llm_settings_ollama_round_trip_with_defaults() {
1404 let toml_in = r#"mode = "ollama""#;
1405 let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
1406 match parsed {
1407 LlmSettings::Ollama {
1408 ref base_url,
1409 ref model,
1410 } => {
1411 assert_eq!(base_url, "http://localhost:11434");
1412 assert_eq!(model, "qwen3-coder:30b");
1413 }
1414 other => panic!("expected Ollama, got {other:?}"),
1415 }
1416 }
1417
1418 #[test]
1419 fn llm_settings_none_round_trips_and_short_circuits() {
1420 let toml_in = r#"mode = "none""#;
1421 let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
1422 assert_eq!(parsed, LlmSettings::None);
1423 assert!(!parsed.is_real_llm());
1424 assert!(!parsed.requires_mcp_peer());
1425 assert_eq!(parsed.mode_str(), "none");
1426 // Default is `none` — fresh installs land here when no env-var
1427 // hint surfaces a backend.
1428 assert_eq!(LlmSettings::default(), LlmSettings::None);
1429 }
1430
1431 #[test]
1432 fn llm_settings_mcp_sampling_parses_and_requires_peer() {
1433 let toml_in = r#"mode = "mcp_sampling""#;
1434 let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
1435 assert_eq!(parsed, LlmSettings::McpSampling);
1436 assert!(parsed.is_real_llm());
1437 assert!(parsed.requires_mcp_peer());
1438 assert_eq!(parsed.mode_str(), "mcp_sampling");
1439 }
1440
1441 #[test]
1442 fn llm_settings_unknown_mode_rejects() {
1443 let toml_in = r#"mode = "qwerty""#;
1444 let err = toml::from_str::<LlmSettings>(toml_in).unwrap_err();
1445 // serde tag mismatch → "unknown variant"
1446 let s = err.to_string();
1447 assert!(
1448 s.contains("unknown variant") || s.contains("qwerty"),
1449 "expected unknown-variant error; got: {s}"
1450 );
1451 }
1452
1453 #[test]
1454 fn llm_settings_validate_against_transport_rejects_sampling_without_mcp() {
1455 // BLOCKER 2 (resolved in plan §3 Decision 4): daemon-mode with
1456 // `mode = "mcp_sampling"` must refuse to start with a clear
1457 // error pointing at the four alternative backends.
1458 let cfg = LlmSettings::McpSampling;
1459 let err = cfg
1460 .validate_against_transport(false)
1461 .expect_err("mcp_sampling without MCP transport must reject");
1462 let msg = err.to_string();
1463 assert!(
1464 msg.contains("mcp_sampling"),
1465 "error must name the offending mode; got: {msg}"
1466 );
1467 assert!(
1468 msg.contains("anthropic") && msg.contains("openai") && msg.contains("ollama") && msg.contains("none"),
1469 "error must list all 4 alternative modes for actionable recovery; got: {msg}"
1470 );
1471 }
1472
1473 #[test]
1474 fn llm_settings_validate_against_transport_allows_sampling_when_mcp_available() {
1475 // When the process has an MCP transport (stdio MCP runs alongside
1476 // an HTTP transport or as the sole transport), `mcp_sampling`
1477 // passes the transport-availability gate. v0.9.0 P2 layers the
1478 // per-session capability check on top, but at config-load time
1479 // the transport presence is enough.
1480 let cfg = LlmSettings::McpSampling;
1481 cfg.validate_against_transport(true)
1482 .expect("mcp_sampling with MCP transport must validate");
1483 }
1484
1485 #[test]
1486 fn llm_settings_validate_against_transport_no_op_for_static_backends() {
1487 // None, Anthropic, OpenAI, Ollama don't require a peer; the
1488 // gate is a no-op for them regardless of mcp_transport_available.
1489 for cfg in [
1490 LlmSettings::None,
1491 LlmSettings::Anthropic {
1492 api_key_env: "X".into(),
1493 model: "y".into(),
1494 },
1495 LlmSettings::Openai {
1496 api_key_env: "X".into(),
1497 model: "y".into(),
1498 },
1499 LlmSettings::Ollama {
1500 base_url: "http://x".into(),
1501 model: "y".into(),
1502 },
1503 ] {
1504 cfg.validate_against_transport(false)
1505 .expect("static backend must validate without MCP transport");
1506 cfg.validate_against_transport(true)
1507 .expect("static backend must validate with MCP transport too");
1508 }
1509 }
1510
1511 /// `SoloConfig` round-trips with an `[llm]` block on disk — this
1512 /// is the integration shape `solo init` + `daemon` will care about.
1513 #[test]
1514 fn solo_config_round_trips_with_llm_block() {
1515 let tmp = TempDir::new().unwrap();
1516 let path = tmp.path().join("solo.config.toml");
1517 std::fs::write(
1518 &path,
1519 format!(
1520 r#"
1521schema_version = {CONFIG_SCHEMA_VERSION}
1522salt_hex = "00000000000000000000000000000000"
1523
1524[embedder]
1525name = "bge-m3"
1526version = "v1.0"
1527dim = 1024
1528dtype = "f32"
1529
1530[llm]
1531mode = "anthropic"
1532api_key_env = "ANTHROPIC_API_KEY"
1533model = "claude-sonnet-4-6"
1534"#
1535 ),
1536 )
1537 .unwrap();
1538 let cfg = SoloConfig::read(&path).expect("read ok");
1539 assert_eq!(
1540 cfg.llm,
1541 Some(LlmSettings::Anthropic {
1542 api_key_env: "ANTHROPIC_API_KEY".into(),
1543 model: "claude-sonnet-4-6".into(),
1544 })
1545 );
1546 }
1547
1548 /// Backward compat: pre-v0.9.0 configs without the `[llm]` block
1549 /// must still parse — the env-var precedence path remains the
1550 /// fallback at this layer.
1551 #[test]
1552 fn solo_config_defaults_llm_to_none_when_block_absent() {
1553 let tmp = TempDir::new().unwrap();
1554 let path = tmp.path().join("solo.config.toml");
1555 std::fs::write(
1556 &path,
1557 format!(
1558 r#"
1559schema_version = {CONFIG_SCHEMA_VERSION}
1560salt_hex = "00000000000000000000000000000000"
1561
1562[embedder]
1563name = "bge-m3"
1564version = "v1.0"
1565dim = 1024
1566dtype = "f32"
1567"#
1568 ),
1569 )
1570 .unwrap();
1571 let cfg = SoloConfig::read(&path).expect("read ok");
1572 assert!(
1573 cfg.llm.is_none(),
1574 "missing [llm] block must deserialize as None (env-var fallback path)"
1575 );
1576 }
1577
1578 // ----------------------------------------------------------------
1579 // v0.9.0 P1 — TriplesConfig defaults + serde shape
1580 // ----------------------------------------------------------------
1581 //
1582 // The block lives at top-level `[triples]` (not `[llm.triples]` as
1583 // the plan's TOML sketch suggested) — see TriplesConfig docstring
1584 // for the Decision-During-Implementation rationale.
1585
1586 /// Default `TriplesConfig` reflects plan MINOR 1 + NEW finding #7:
1587 /// hourly cadence, 50-episode burst threshold, hourly consolidate.
1588 #[test]
1589 fn triples_config_default_matches_plan_defaults() {
1590 let t = TriplesConfig::default();
1591 assert_eq!(t.trigger_interval_secs, 3600, "MINOR 1: hourly cadence");
1592 assert_eq!(t.trigger_episode_count, 50);
1593 assert_eq!(
1594 t.consolidate_interval_secs, 3600,
1595 "NEW finding #7: TOML-level default flipped to 3600 for new installs"
1596 );
1597 assert_eq!(
1598 t.cluster_timeout_secs, 60,
1599 "v0.10.1 m5: per-cluster LLM call inside extract_triples_batch \
1600 gets a 60-second default ceiling"
1601 );
1602 }
1603
1604 /// v0.10.1 m5: operators can override the per-cluster timeout
1605 /// from TOML.
1606 #[test]
1607 fn solo_config_loads_custom_cluster_timeout_secs() {
1608 let tmp = TempDir::new().unwrap();
1609 let path = tmp.path().join("solo.config.toml");
1610 std::fs::write(
1611 &path,
1612 format!(
1613 r#"
1614schema_version = {CONFIG_SCHEMA_VERSION}
1615salt_hex = "00000000000000000000000000000000"
1616
1617[embedder]
1618name = "bge-m3"
1619version = "v1.0"
1620dim = 1024
1621dtype = "f32"
1622
1623[triples]
1624cluster_timeout_secs = 10
1625"#
1626 ),
1627 )
1628 .unwrap();
1629 let cfg = SoloConfig::read(&path).expect("read ok");
1630 assert_eq!(cfg.triples.cluster_timeout_secs, 10);
1631 // Other knobs fall back to defaults.
1632 assert_eq!(cfg.triples.trigger_interval_secs, 3600);
1633 assert_eq!(cfg.triples.trigger_episode_count, 50);
1634 }
1635
1636 /// Backward compat: pre-v0.9.0 configs without the `[triples]`
1637 /// block must deserialize with the plan's defaults applied.
1638 #[test]
1639 fn solo_config_defaults_triples_block_when_absent() {
1640 let tmp = TempDir::new().unwrap();
1641 let path = tmp.path().join("solo.config.toml");
1642 std::fs::write(
1643 &path,
1644 format!(
1645 r#"
1646schema_version = {CONFIG_SCHEMA_VERSION}
1647salt_hex = "00000000000000000000000000000000"
1648
1649[embedder]
1650name = "bge-m3"
1651version = "v1.0"
1652dim = 1024
1653dtype = "f32"
1654"#
1655 ),
1656 )
1657 .unwrap();
1658 let cfg = SoloConfig::read(&path).expect("read ok");
1659 assert_eq!(cfg.triples, TriplesConfig::default());
1660 }
1661
1662 /// Operator-supplied `[triples]` overrides each default
1663 /// independently.
1664 #[test]
1665 fn solo_config_loads_custom_triples_block() {
1666 let tmp = TempDir::new().unwrap();
1667 let path = tmp.path().join("solo.config.toml");
1668 std::fs::write(
1669 &path,
1670 format!(
1671 r#"
1672schema_version = {CONFIG_SCHEMA_VERSION}
1673salt_hex = "00000000000000000000000000000000"
1674
1675[embedder]
1676name = "bge-m3"
1677version = "v1.0"
1678dim = 1024
1679dtype = "f32"
1680
1681[triples]
1682trigger_interval_secs = 900
1683trigger_episode_count = 25
1684consolidate_interval_secs = 1800
1685cluster_timeout_secs = 45
1686"#
1687 ),
1688 )
1689 .unwrap();
1690 let cfg = SoloConfig::read(&path).expect("read ok");
1691 assert_eq!(cfg.triples.trigger_interval_secs, 900);
1692 assert_eq!(cfg.triples.trigger_episode_count, 25);
1693 assert_eq!(cfg.triples.consolidate_interval_secs, 1800);
1694 assert_eq!(cfg.triples.cluster_timeout_secs, 45);
1695 }
1696
1697 /// Partial `[triples]` keeps unrelated defaults — each field
1698 /// has its own `#[serde(default = "...")]`.
1699 #[test]
1700 fn solo_config_triples_partial_keeps_other_defaults() {
1701 let tmp = TempDir::new().unwrap();
1702 let path = tmp.path().join("solo.config.toml");
1703 std::fs::write(
1704 &path,
1705 format!(
1706 r#"
1707schema_version = {CONFIG_SCHEMA_VERSION}
1708salt_hex = "00000000000000000000000000000000"
1709
1710[embedder]
1711name = "bge-m3"
1712version = "v1.0"
1713dim = 1024
1714dtype = "f32"
1715
1716[triples]
1717trigger_episode_count = 10
1718"#
1719 ),
1720 )
1721 .unwrap();
1722 let cfg = SoloConfig::read(&path).expect("read ok");
1723 assert_eq!(cfg.triples.trigger_episode_count, 10);
1724 // Other knobs fall back to defaults.
1725 assert_eq!(cfg.triples.trigger_interval_secs, 3600);
1726 assert_eq!(cfg.triples.consolidate_interval_secs, 3600);
1727 assert_eq!(cfg.triples.cluster_timeout_secs, 60);
1728 }
1729
1730 // ----------------------------------------------------------------
1731 // v0.9.0 P4d — SamplingConfig defaults + serde shape
1732 // ----------------------------------------------------------------
1733 //
1734 // The `[sampling]` block carries the `SamplingCoordinator`'s
1735 // coalesce knobs (`coalesce_window_ms` + `coalesce_max_requests`).
1736 // Defaults match plan §4 P4d (5000ms window + 10-request max-batch).
1737
1738 /// Default `SamplingConfig` matches plan §4 P4d.
1739 #[test]
1740 fn sampling_config_default_matches_plan_defaults() {
1741 let s = SamplingConfig::default();
1742 assert_eq!(s.coalesce_window_ms, 5000);
1743 assert_eq!(s.coalesce_max_requests, 10);
1744 }
1745
1746 /// Backward compat: pre-P4d configs without the `[sampling]`
1747 /// block deserialize to defaults (zero behaviour change for
1748 /// every v0.8.x config).
1749 #[test]
1750 fn solo_config_defaults_sampling_block_when_absent() {
1751 let tmp = TempDir::new().unwrap();
1752 let path = tmp.path().join("solo.config.toml");
1753 std::fs::write(
1754 &path,
1755 format!(
1756 r#"
1757schema_version = {CONFIG_SCHEMA_VERSION}
1758salt_hex = "00000000000000000000000000000000"
1759
1760[embedder]
1761name = "bge-m3"
1762version = "v1.0"
1763dim = 1024
1764dtype = "f32"
1765"#
1766 ),
1767 )
1768 .unwrap();
1769 let cfg = SoloConfig::read(&path).expect("read ok");
1770 assert_eq!(cfg.sampling, SamplingConfig::default());
1771 }
1772
1773 /// Operator-supplied `[sampling]` overrides each knob.
1774 #[test]
1775 fn solo_config_loads_custom_sampling_block() {
1776 let tmp = TempDir::new().unwrap();
1777 let path = tmp.path().join("solo.config.toml");
1778 std::fs::write(
1779 &path,
1780 format!(
1781 r#"
1782schema_version = {CONFIG_SCHEMA_VERSION}
1783salt_hex = "00000000000000000000000000000000"
1784
1785[embedder]
1786name = "bge-m3"
1787version = "v1.0"
1788dim = 1024
1789dtype = "f32"
1790
1791[sampling]
1792coalesce_window_ms = 1500
1793coalesce_max_requests = 5
1794"#
1795 ),
1796 )
1797 .unwrap();
1798 let cfg = SoloConfig::read(&path).expect("read ok");
1799 assert_eq!(cfg.sampling.coalesce_window_ms, 1500);
1800 assert_eq!(cfg.sampling.coalesce_max_requests, 5);
1801 }
1802
1803 /// Partial `[sampling]` keeps unrelated defaults — each field
1804 /// has its own `#[serde(default = "...")]`.
1805 #[test]
1806 fn solo_config_sampling_partial_keeps_other_defaults() {
1807 let tmp = TempDir::new().unwrap();
1808 let path = tmp.path().join("solo.config.toml");
1809 std::fs::write(
1810 &path,
1811 format!(
1812 r#"
1813schema_version = {CONFIG_SCHEMA_VERSION}
1814salt_hex = "00000000000000000000000000000000"
1815
1816[embedder]
1817name = "bge-m3"
1818version = "v1.0"
1819dim = 1024
1820dtype = "f32"
1821
1822[sampling]
1823coalesce_max_requests = 25
1824"#
1825 ),
1826 )
1827 .unwrap();
1828 let cfg = SoloConfig::read(&path).expect("read ok");
1829 assert_eq!(cfg.sampling.coalesce_max_requests, 25);
1830 assert_eq!(
1831 cfg.sampling.coalesce_window_ms, 5000,
1832 "unset knob falls back to default"
1833 );
1834 }
1835
1836 // ----------------------------------------------------------------
1837 // v0.9.1 P1 Fix 5 (m3) — SamplingConfig edge value diagnostic
1838 // ----------------------------------------------------------------
1839 //
1840 // The coordinator clamps `coalesce_max_requests.max(1)` internally
1841 // (so 0 → 1 in practice), and `coalesce_window_ms = 0` flushes the
1842 // buffered timer immediately. Together these two edge values make
1843 // the coordinator a pass-through. We don't reject (operators may
1844 // want pass-through), but we do surface the resolved settings.
1845
1846 /// `diagnostic()` returns `Warn` when both bounds disable
1847 /// coalescing, `Info` when only one is zero, and `Ok` for the
1848 /// default + every healthy combination.
1849 #[test]
1850 fn sampling_config_diagnostic_classifies_edge_values() {
1851 // Default — healthy.
1852 assert_eq!(
1853 SamplingConfig::default().diagnostic(),
1854 SamplingConfigDiagnostic::Ok
1855 );
1856
1857 // Both bounds zeroed (operator opted into pass-through).
1858 let disabled = SamplingConfig {
1859 coalesce_window_ms: 0,
1860 coalesce_max_requests: 0,
1861 };
1862 assert_eq!(disabled.diagnostic(), SamplingConfigDiagnostic::Warn);
1863
1864 // Window=0 + max_requests=1 (post-clamp equivalent) — same
1865 // pass-through behaviour, classified the same way.
1866 let disabled_clamped = SamplingConfig {
1867 coalesce_window_ms: 0,
1868 coalesce_max_requests: 1,
1869 };
1870 assert_eq!(
1871 disabled_clamped.diagnostic(),
1872 SamplingConfigDiagnostic::Warn
1873 );
1874
1875 // Only window zero — coordinator still coalesces up to
1876 // max_requests=10. Worth logging at info, not a warning.
1877 let info_window = SamplingConfig {
1878 coalesce_window_ms: 0,
1879 coalesce_max_requests: 10,
1880 };
1881 assert_eq!(info_window.diagnostic(), SamplingConfigDiagnostic::Info);
1882
1883 // Only max_requests zero — coordinator flushes when the
1884 // window timer fires.
1885 let info_max = SamplingConfig {
1886 coalesce_window_ms: 5000,
1887 coalesce_max_requests: 0,
1888 };
1889 assert_eq!(info_max.diagnostic(), SamplingConfigDiagnostic::Info);
1890
1891 // Healthy non-default — Ok.
1892 let custom = SamplingConfig {
1893 coalesce_window_ms: 250,
1894 coalesce_max_requests: 3,
1895 };
1896 assert_eq!(custom.diagnostic(), SamplingConfigDiagnostic::Ok);
1897 }
1898
1899 /// `warn_on_edge_values()` does not panic for any classification
1900 /// (including the `Ok` and `Info` no-op paths). Trust the
1901 /// classification test above for behavior; this test just pins
1902 /// that wiring the warn-emitter into `read` is safe even when the
1903 /// daemon has no tracing subscriber installed.
1904 #[test]
1905 fn sampling_config_warn_on_edge_values_does_not_panic() {
1906 SamplingConfig::default().warn_on_edge_values();
1907 SamplingConfig {
1908 coalesce_window_ms: 0,
1909 coalesce_max_requests: 0,
1910 }
1911 .warn_on_edge_values();
1912 SamplingConfig {
1913 coalesce_window_ms: 0,
1914 coalesce_max_requests: 10,
1915 }
1916 .warn_on_edge_values();
1917 SamplingConfig {
1918 coalesce_window_ms: 5000,
1919 coalesce_max_requests: 0,
1920 }
1921 .warn_on_edge_values();
1922 }
1923
1924 /// End-to-end pin: a `[sampling]` block with both bounds zeroed
1925 /// parses cleanly through `SoloConfig::read` (no rejection — the
1926 /// validation is informational only).
1927 #[test]
1928 fn solo_config_read_accepts_disabled_coalescing_block() {
1929 let tmp = TempDir::new().unwrap();
1930 let path = tmp.path().join("solo.config.toml");
1931 std::fs::write(
1932 &path,
1933 format!(
1934 r#"
1935schema_version = {CONFIG_SCHEMA_VERSION}
1936salt_hex = "00000000000000000000000000000000"
1937
1938[embedder]
1939name = "bge-m3"
1940version = "v1.0"
1941dim = 1024
1942dtype = "f32"
1943
1944[sampling]
1945coalesce_window_ms = 0
1946coalesce_max_requests = 0
1947"#
1948 ),
1949 )
1950 .unwrap();
1951 let cfg = SoloConfig::read(&path).expect("read ok");
1952 assert_eq!(cfg.sampling.coalesce_window_ms, 0);
1953 assert_eq!(cfg.sampling.coalesce_max_requests, 0);
1954 assert_eq!(
1955 cfg.sampling.diagnostic(),
1956 SamplingConfigDiagnostic::Warn,
1957 "0/0 must classify Warn — the warning gets emitted to \
1958 tracing during read()"
1959 );
1960 }
1961
1962 // ----------------------------------------------------------------
1963 // v0.11.1 — `[steward]` TOML block parsing
1964 // ----------------------------------------------------------------
1965 //
1966 // Both fields are `Option<T>`; an absent block (or absent field)
1967 // surfaces as `None`, which the daemon-side resolution
1968 // (`StewardConfig::from_settings_then_env`) maps to "use the
1969 // built-in default for that field". Backward-compatible with every
1970 // existing solo.config.toml — no migration required.
1971
1972 /// Pre-v0.11.1 configs (or operators sticking with env vars only)
1973 /// have no `[steward]` block. They must deserialize cleanly with
1974 /// `cluster_min_size` and `cluster_cosine_threshold` both `None`.
1975 #[test]
1976 fn read_defaults_steward_when_block_absent() {
1977 let tmp = TempDir::new().unwrap();
1978 let path = tmp.path().join("solo.config.toml");
1979 std::fs::write(
1980 &path,
1981 format!(
1982 r#"
1983schema_version = {CONFIG_SCHEMA_VERSION}
1984salt_hex = "00000000000000000000000000000000"
1985
1986[embedder]
1987name = "bge-m3"
1988version = "v1.0"
1989dim = 1024
1990dtype = "f32"
1991"#
1992 ),
1993 )
1994 .unwrap();
1995 let cfg = SoloConfig::read(&path).expect("read ok");
1996 assert!(cfg.steward.cluster_min_size.is_none());
1997 assert!(cfg.steward.cluster_cosine_threshold.is_none());
1998 }
1999
2000 /// Both fields explicit + valid: the parsed values surface unchanged
2001 /// (validation happens at the daemon-side `from_settings_then_env`
2002 /// step). This pins the type + name spelling expected at the wire
2003 /// level: `cluster_min_size = <int>`, `cluster_cosine_threshold = <float>`.
2004 #[test]
2005 fn read_loads_steward_block_with_both_overrides() {
2006 let tmp = TempDir::new().unwrap();
2007 let path = tmp.path().join("solo.config.toml");
2008 std::fs::write(
2009 &path,
2010 format!(
2011 r#"
2012schema_version = {CONFIG_SCHEMA_VERSION}
2013salt_hex = "00000000000000000000000000000000"
2014
2015[embedder]
2016name = "bge-m3"
2017version = "v1.0"
2018dim = 1024
2019dtype = "f32"
2020
2021[steward]
2022cluster_min_size = 4
2023cluster_cosine_threshold = 0.7
2024"#
2025 ),
2026 )
2027 .unwrap();
2028 let cfg = SoloConfig::read(&path).expect("read ok");
2029 assert_eq!(cfg.steward.cluster_min_size, Some(4));
2030 assert_eq!(cfg.steward.cluster_cosine_threshold, Some(0.7));
2031 }
2032
2033 /// Empty `[steward]` block (operator added the header but no fields)
2034 /// or a partial block must also deserialize cleanly. This guards
2035 /// against the common "I'll come back and fill this in later" path.
2036 #[test]
2037 fn read_loads_steward_block_with_partial_overrides() {
2038 let tmp = TempDir::new().unwrap();
2039 let path = tmp.path().join("solo.config.toml");
2040 std::fs::write(
2041 &path,
2042 format!(
2043 r#"
2044schema_version = {CONFIG_SCHEMA_VERSION}
2045salt_hex = "00000000000000000000000000000000"
2046
2047[embedder]
2048name = "bge-m3"
2049version = "v1.0"
2050dim = 1024
2051dtype = "f32"
2052
2053[steward]
2054cluster_min_size = 5
2055"#
2056 ),
2057 )
2058 .unwrap();
2059 let cfg = SoloConfig::read(&path).expect("read ok");
2060 assert_eq!(cfg.steward.cluster_min_size, Some(5));
2061 assert!(
2062 cfg.steward.cluster_cosine_threshold.is_none(),
2063 "field omitted from block — should be None, daemon resolves to default"
2064 );
2065 }
2066}