Skip to main content

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}