Skip to main content

ai_memory/
config.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7use crate::models::Tier;
8
9// ---------------------------------------------------------------------------
10// Embedding models
11// ---------------------------------------------------------------------------
12
13/// Supported embedding models for semantic search.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum EmbeddingModel {
17    /// sentence-transformers/all-MiniLM-L6-v2 — 384-dim, ~90 MB
18    MiniLmL6V2,
19    /// nomic-ai/nomic-embed-text-v1.5 — 768-dim, ~270 MB
20    NomicEmbedV15,
21}
22
23impl EmbeddingModel {
24    /// Embedding vector dimensionality.
25    pub fn dim(self) -> usize {
26        match self {
27            Self::MiniLmL6V2 => 384,
28            Self::NomicEmbedV15 => 768,
29        }
30    }
31
32    /// `HuggingFace` model identifier.
33    pub fn hf_model_id(&self) -> &str {
34        match self {
35            Self::MiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
36            Self::NomicEmbedV15 => "nomic-ai/nomic-embed-text-v1.5",
37        }
38    }
39}
40
41// ---------------------------------------------------------------------------
42// LLM models
43// ---------------------------------------------------------------------------
44
45/// Supported LLM models (served via Ollama).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LlmModel {
49    /// Google Gemma 4 Effective 2B — ~1 GB Q4
50    Gemma4E2B,
51    /// Google Gemma 4 Effective 4B — ~2.3 GB Q4
52    Gemma4E4B,
53}
54
55impl LlmModel {
56    /// Ollama model tag used to pull / run this model.
57    pub fn ollama_model_id(&self) -> &str {
58        match self {
59            Self::Gemma4E2B => "gemma4:e2b",
60            Self::Gemma4E4B => "gemma4:e4b",
61        }
62    }
63
64    /// Human-readable display name.
65    pub fn display_name(&self) -> &str {
66        match self {
67            Self::Gemma4E2B => "Gemma 4 Effective 2B (Q4)",
68            Self::Gemma4E4B => "Gemma 4 Effective 4B (Q4)",
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Feature tiers
75// ---------------------------------------------------------------------------
76
77/// Feature tiers control which AI capabilities are active based on the
78/// available memory budget on the host machine.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum FeatureTier {
82    /// FTS5 keyword search only — 0 MB extra.
83    Keyword,
84    /// `MiniLM` embeddings + HNSW index — ~256 MB.
85    Semantic,
86    /// nomic-embed + Gemma 4 E2B via Ollama — ~1 GB.
87    Smart,
88    /// nomic-embed + Gemma 4 E4B + cross-encoder via Ollama — ~4 GB.
89    Autonomous,
90}
91
92impl FeatureTier {
93    /// Parse a tier name (case-insensitive).
94    pub fn from_str(s: &str) -> Option<Self> {
95        match s.to_ascii_lowercase().as_str() {
96            "keyword" => Some(Self::Keyword),
97            "semantic" => Some(Self::Semantic),
98            "smart" => Some(Self::Smart),
99            "autonomous" => Some(Self::Autonomous),
100            _ => None,
101        }
102    }
103
104    /// Canonical lowercase name.
105    pub fn as_str(&self) -> &str {
106        match self {
107            Self::Keyword => "keyword",
108            Self::Semantic => "semantic",
109            Self::Smart => "smart",
110            Self::Autonomous => "autonomous",
111        }
112    }
113
114    /// Build the full [`TierConfig`] for this tier.
115    pub fn config(self) -> TierConfig {
116        match self {
117            Self::Keyword => TierConfig {
118                tier: self,
119                embedding_model: None,
120                llm_model: None,
121                cross_encoder: false,
122                max_memory_mb: 0,
123            },
124            Self::Semantic => TierConfig {
125                tier: self,
126                embedding_model: Some(EmbeddingModel::MiniLmL6V2),
127                llm_model: None,
128                cross_encoder: false,
129                max_memory_mb: 256,
130            },
131            Self::Smart => TierConfig {
132                tier: self,
133                embedding_model: Some(EmbeddingModel::NomicEmbedV15),
134                llm_model: Some(LlmModel::Gemma4E2B),
135                cross_encoder: false,
136                max_memory_mb: 1024,
137            },
138            Self::Autonomous => TierConfig {
139                tier: self,
140                embedding_model: Some(EmbeddingModel::NomicEmbedV15),
141                llm_model: Some(LlmModel::Gemma4E4B),
142                cross_encoder: true,
143                max_memory_mb: 4096,
144            },
145        }
146    }
147
148    /// Automatically select the best tier that fits within `mb` megabytes.
149    #[allow(dead_code)]
150    pub fn from_memory_budget(mb: usize) -> Self {
151        if mb >= 4096 {
152            Self::Autonomous
153        } else if mb >= 1024 {
154            Self::Smart
155        } else if mb >= 256 {
156            Self::Semantic
157        } else {
158            Self::Keyword
159        }
160    }
161}
162
163impl std::fmt::Display for FeatureTier {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(self.as_str())
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Tier configuration
171// ---------------------------------------------------------------------------
172
173/// Runtime configuration derived from a [`FeatureTier`].
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct TierConfig {
176    pub tier: FeatureTier,
177    pub embedding_model: Option<EmbeddingModel>,
178    pub llm_model: Option<LlmModel>,
179    pub cross_encoder: bool,
180    pub max_memory_mb: usize,
181}
182
183impl TierConfig {
184    /// Produce a [`Capabilities`] (schema v2) report suitable for JSON
185    /// serialisation. The MCP / HTTP `handle_capabilities_with_conn`
186    /// wrapper overlays live runtime state (recall mode, reranker mode,
187    /// embedder-loaded flag) and live DB counts (active rules, hook
188    /// registrations, pending approvals) before the report goes on the
189    /// wire.
190    ///
191    /// v2 honesty patch (P1, v0.6.3.1): `recall_mode_active` and
192    /// `reranker_active` start at conservative defaults (`disabled` /
193    /// `off`); the wrapper updates them based on the *runtime* embedder
194    /// + reranker handles, not the *configured* tier values.
195    pub fn capabilities(&self) -> Capabilities {
196        let has_embeddings = self.embedding_model.is_some();
197        let has_llm = self.llm_model.is_some();
198
199        Capabilities {
200            // Capabilities schema v2 — see `Capabilities` doc comment.
201            schema_version: "2".to_string(),
202            tier: self.tier.as_str().to_string(),
203            version: env!("CARGO_PKG_VERSION").to_string(),
204            features: CapabilityFeatures {
205                keyword_search: true,
206                semantic_search: has_embeddings,
207                hybrid_recall: has_embeddings,
208                query_expansion: has_llm,
209                auto_consolidation: has_llm,
210                auto_tagging: has_llm,
211                contradiction_analysis: has_llm,
212                cross_encoder_reranking: self.cross_encoder,
213                // Honesty patch: planned-not-implemented. The flag was
214                // previously a `bool` whose `true` value implied a wired
215                // feature that does not exist in this build.
216                memory_reflection: PlannedFeature::planned("v0.7+"),
217                // Default false — the HTTP/MCP capabilities handler
218                // overwrites this with the live runtime state when it
219                // has access to the embedder handle.
220                embedder_loaded: false,
221                // Conservative defaults; the handler wrapper overlays the
222                // live runtime state (`hybrid` when embedder is loaded,
223                // `keyword_only` when it is not, `degraded` if the load
224                // failed, `disabled` for the keyword tier).
225                recall_mode_active: RecallMode::Disabled,
226                // Conservative default; overwritten when the wrapper has
227                // the actual reranker handle. `off` means no reranker is
228                // configured; `lexical_fallback` means the neural model
229                // failed to materialize; `neural` means the BERT
230                // cross-encoder is loaded.
231                reranker_active: RerankerMode::Off,
232            },
233            models: CapabilityModels {
234                embedding: self
235                    .embedding_model
236                    .map_or_else(|| "none".to_string(), |m| m.hf_model_id().to_string()),
237                embedding_dim: self.embedding_model.map_or(0, EmbeddingModel::dim),
238                llm: self
239                    .llm_model
240                    .map_or_else(|| "none".to_string(), |m| m.ollama_model_id().to_string()),
241                cross_encoder: if self.cross_encoder {
242                    "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string()
243                } else {
244                    "none".to_string()
245                },
246            },
247            // v2 dynamic blocks — start at zero-state defaults. The MCP
248            // and HTTP `handle_capabilities` wrappers overwrite these
249            // with live counts when they have a `&Connection` handle.
250            //
251            // Honesty patch (P1): `permissions.mode` is `"advisory"`
252            // until P4 lands the enforcement gate. Was `"ask"`, which
253            // implied an active prompt loop that does not exist.
254            // `rule_summary`, `hooks.by_event`, `approval.subscribers`,
255            // and `approval.default_timeout_seconds` were dropped in v2
256            // because they have no backing implementation.
257            permissions: CapabilityPermissions {
258                mode: "advisory".to_string(),
259                active_rules: 0,
260                // v0.6.3.1 (P4, G1): chain-walking enforcement landed
261                // in this release. Surface "enforced" so consumers can
262                // distinguish a governed deployment from the historical
263                // "display_only" posture.
264                inheritance: Some("enforced".to_string()),
265            },
266            hooks: CapabilityHooks::default(),
267            compaction: CapabilityCompaction::planned(),
268            approval: CapabilityApproval {
269                pending_requests: 0,
270            },
271            transcripts: CapabilityTranscripts::planned(),
272            hnsw: CapabilityHnsw::default(),
273        }
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Capability reporting
279// ---------------------------------------------------------------------------
280
281/// Top-level capabilities report for a running instance.
282///
283/// Schema versions:
284/// - **v1** (legacy, pre-v0.6.3.1): `tier`, `version`, `features`,
285///   `models`. Reachable via `Accept-Capabilities: v1` (HTTP) or the MCP
286///   `accept` argument set to `"v1"`. See [`CapabilitiesV1`].
287/// - **v2** (v0.6.3.1 honesty patch): `schema_version="2"` plus the
288///   `permissions`, `hooks`, `compaction`, `approval`, `transcripts`
289///   blocks. v1 fields preserved at the same top-level paths — old
290///   clients that read v2 by name continue to work for the un-dropped
291///   fields. Default response shape.
292///
293/// **v2 honesty patch (P1, v0.6.3.1):**
294/// - `features.recall_mode_active` and `features.reranker_active` are
295///   *runtime* state, not config-derived flags.
296/// - `features.memory_reflection` is now a `{planned, version, enabled}`
297///   object, not a `bool`.
298/// - `compaction` and `transcripts` carry the same planned-feature
299///   shape so operators can distinguish "disabled but built" from "not
300///   in this build."
301/// - `permissions.mode = "advisory"` until the enforcement gate ships
302///   in P4. Was `"ask"`, which implied an active interactive loop.
303/// - The following fields were **removed** because no backing
304///   implementation exists: `permissions.rule_summary`,
305///   `hooks.by_event`, `approval.subscribers`,
306///   `approval.default_timeout_seconds`.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct Capabilities {
309    /// Schema-version discriminator. Always `"2"` since v0.6.3.
310    pub schema_version: String,
311    pub tier: String,
312    pub version: String,
313    pub features: CapabilityFeatures,
314    pub models: CapabilityModels,
315
316    /// Active permission/governance rules. Pre-P4 reports the count of
317    /// namespaces that have a `metadata.governance` policy attached to
318    /// their standard memory; the underlying permission system itself
319    /// is P4 work.
320    pub permissions: CapabilityPermissions,
321
322    /// Registered hooks. Pre-v0.7 reports webhook subscriptions as a
323    /// proxy (hook system itself is v0.7 Bucket 0).
324    pub hooks: CapabilityHooks,
325
326    /// Compaction state. v0.8 work — reports `{planned, version,
327    /// enabled}` until the subsystem ships.
328    pub compaction: CapabilityCompaction,
329
330    /// Approval API state. Reports the live count of pending actions
331    /// from the existing `pending_actions` table.
332    pub approval: CapabilityApproval,
333
334    /// Sidechain-transcript state. v0.7 Bucket 1.7 work — reports
335    /// `{planned, version, enabled}` until the subsystem ships.
336    pub transcripts: CapabilityTranscripts,
337
338    /// v0.6.3.1 (P3, G2): HNSW vector-index health. Defaults to a
339    /// quiet zero-state report; the MCP/HTTP capabilities wrapper
340    /// overwrites with live process counters when the index module
341    /// has run an eviction.
342    #[serde(default)]
343    pub hnsw: CapabilityHnsw,
344}
345
346/// Live recall-mode tag (P1 honesty patch). Reflects the *runtime*
347/// state of the embedder + LLM, not the configured tier.
348///
349/// - `Hybrid` — embedder loaded; semantic + keyword blending active.
350/// - `KeywordOnly` — no embedder loaded; FTS5 only.
351/// - `Degraded` — embedder configured but `Embedder::load()` failed
352///   (offline runner, read-only fs, missing HF token, etc.).
353/// - `Disabled` — keyword-tier daemon, semantic recall not configured.
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "snake_case")]
356pub enum RecallMode {
357    Hybrid,
358    KeywordOnly,
359    Degraded,
360    Disabled,
361}
362
363/// Live reranker-mode tag (P1 honesty patch). Reflects the *runtime*
364/// `CrossEncoder` enum variant, not the configured `cross_encoder` flag.
365///
366/// - `Neural` — `CrossEncoder::Neural` loaded successfully.
367/// - `LexicalFallback` — `cross_encoder` was requested but neural model
368///   download or load failed; running on the lexical scorer.
369/// - `Off` — no reranker handle in the daemon (non-autonomous tier).
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "snake_case")]
372pub enum RerankerMode {
373    Neural,
374    LexicalFallback,
375    Off,
376}
377
378/// Generic "planned but not implemented" marker used by v2 capability
379/// fields whose underlying subsystem is on the roadmap but not in this
380/// build. Operators reading the JSON can distinguish "disabled but
381/// available" from "not in this build" by inspecting `planned`.
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
383pub struct PlannedFeature {
384    /// `true` when the feature exists only on the roadmap.
385    pub planned: bool,
386    /// Earliest release that is expected to ship the feature, e.g.
387    /// `"v0.7+"` or `"v0.8+"`. Free-form string; clients should treat
388    /// it as advisory.
389    pub version: String,
390    /// `true` only when the feature is built **and** turned on in this
391    /// daemon. Always `false` when `planned == true`.
392    pub enabled: bool,
393}
394
395impl PlannedFeature {
396    /// A planned-not-yet-shipped feature. `enabled = false`.
397    #[must_use]
398    pub fn planned(version: &str) -> Self {
399        Self {
400            planned: true,
401            version: version.to_string(),
402            enabled: false,
403        }
404    }
405}
406
407/// Boolean feature flags exposed in the capabilities report.
408#[allow(clippy::struct_excessive_bools)]
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct CapabilityFeatures {
411    pub keyword_search: bool,
412    pub semantic_search: bool,
413    pub hybrid_recall: bool,
414    pub query_expansion: bool,
415    pub auto_consolidation: bool,
416    pub auto_tagging: bool,
417    pub contradiction_analysis: bool,
418    pub cross_encoder_reranking: bool,
419    /// Memory-reflection (v0.7+): planned, not yet implemented.
420    /// Was a `bool` before the P1 honesty patch; an object now so
421    /// operators can tell "feature exists but disabled" apart from
422    /// "feature not in this build".
423    pub memory_reflection: PlannedFeature,
424    /// v0.6.2 (S18): runtime-observed embedder state. `semantic_search`
425    /// above reflects *configured* capability (derived from the tier's
426    /// `embedding_model` setting). `embedder_loaded` reflects *actual*
427    /// state after `Embedder::load()` attempted to materialize the
428    /// `HuggingFace` model on startup. When an operator configures the
429    /// `semantic` tier but the model download or mmap fails (offline
430    /// runner, read-only fs, missing tokens), `semantic_search=true`
431    /// would mislead. This flag exposes the truth so setup scripts can
432    /// assert the daemon is actually ready for semantic recall before
433    /// dispatching scenarios. Default false; populated by
434    /// `handle_capabilities` when the HTTP/MCP wrapper hands in the
435    /// live embedder handle.
436    #[serde(default)]
437    pub embedder_loaded: bool,
438    /// v0.6.3.1 (P1 honesty patch): runtime recall-mode tag. Reflects
439    /// the live embedder + LLM availability, not the configured tier.
440    /// See [`RecallMode`].
441    #[serde(default = "default_recall_mode")]
442    pub recall_mode_active: RecallMode,
443    /// v0.6.3.1 (P1 honesty patch): runtime reranker-mode tag.
444    /// Reflects the live `CrossEncoder` variant. See [`RerankerMode`].
445    #[serde(default = "default_reranker_mode")]
446    pub reranker_active: RerankerMode,
447}
448
449fn default_recall_mode() -> RecallMode {
450    RecallMode::Disabled
451}
452
453fn default_reranker_mode() -> RerankerMode {
454    RerankerMode::Off
455}
456
457/// Model identifiers exposed in the capabilities report.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct CapabilityModels {
460    pub embedding: String,
461    pub embedding_dim: usize,
462    pub llm: String,
463    pub cross_encoder: String,
464}
465
466/// Permissions block (capabilities schema v2). Pre-P4 reports a live
467/// count of namespace standards carrying a `metadata.governance` policy;
468/// the full enforcement gate lands in P4. The honesty patch (P1)
469/// renames the mode from `"ask"` (which implied an interactive prompt
470/// loop) to `"advisory"` (governance metadata is recorded but not
471/// enforced).
472#[derive(Debug, Clone, Serialize, Deserialize, Default)]
473pub struct CapabilityPermissions {
474    /// Enforcement mode. `"advisory"` until P4 ships the gate.
475    pub mode: String,
476    /// Number of namespace standards whose `metadata.governance` is
477    /// non-null. Counts policies, not memories.
478    pub active_rules: usize,
479    // P1 honesty patch: `rule_summary` was always empty — no per-rule
480    // serializer existed. Dropped from the v2 wire schema.
481    /// v0.6.3.1 (P4, audit G1): governance-inheritance posture.
482    /// `"enforced"` = `resolve_governance_policy` walks the namespace
483    /// chain leaf-first and returns the most-specific policy (with
484    /// `inherit: false` short-circuiting). Pre-v0.6.3.1 was
485    /// `"display_only"` — the UI surfaced the chain but the gate
486    /// consulted only the leaf, leaving children of governed parents
487    /// completely ungoverned. The field is `Option<String>` so older
488    /// capabilities responses (without the field) round-trip cleanly
489    /// via `#[serde(default)]`.
490    #[serde(default)]
491    pub inheritance: Option<String>,
492}
493
494/// Hook-pipeline block (capabilities schema v2). Pre-v0.7 reports webhook
495/// subscriptions as the closest analogue. The full hook pipeline lands in
496/// v0.7 Bucket 0 (arch-enhancement-spec §2).
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct CapabilityHooks {
499    /// Number of registered hook subscribers (proxy: webhook subscriptions).
500    pub registered_count: usize,
501    // P1 honesty patch: `by_event` was always an empty map — no event
502    // registry exists. Dropped from the v2 wire schema.
503    /// v0.6.3.1 P5 (G9): canonical list of webhook event types the
504    /// daemon emits. Integrators pin the `subscribe(event_types: …)`
505    /// filter against these strings. Always populated so downstream
506    /// callers do not have to handle a missing field.
507    #[serde(default = "default_webhook_events")]
508    pub webhook_events: Vec<String>,
509}
510
511impl Default for CapabilityHooks {
512    fn default() -> Self {
513        Self {
514            registered_count: 0,
515            webhook_events: default_webhook_events(),
516        }
517    }
518}
519
520/// Default webhook events list — kept in sync with
521/// `crate::subscriptions::WEBHOOK_EVENT_TYPES`. The constant lives in
522/// `subscriptions.rs` (the surface that uses it at runtime); this
523/// helper exists so `serde(default = …)` and `CapabilityHooks::default`
524/// can fill the field without a cross-module dep on `subscriptions`.
525fn default_webhook_events() -> Vec<String> {
526    vec![
527        "memory_store".to_string(),
528        "memory_promote".to_string(),
529        "memory_delete".to_string(),
530        "memory_link_created".to_string(),
531        "memory_consolidated".to_string(),
532    ]
533}
534
535/// Compaction block (capabilities schema v2). v0.8 Pillar 2.5 work —
536/// reports `{planned, version, enabled}` plus optional run stats. The
537/// honesty patch (P1) replaced the bare `enabled: false` with the
538/// planned-feature shape so operators can distinguish "feature exists
539/// but disabled" from "feature not in this build".
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct CapabilityCompaction {
542    /// Planned-feature marker. `planned = true` while compaction lives
543    /// only on the roadmap. When the subsystem ships the daemon will
544    /// flip `planned = false` and `enabled` will reflect runtime state.
545    #[serde(flatten)]
546    pub status: PlannedFeature,
547    /// Once shipped: scheduled compaction interval in minutes.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub interval_minutes: Option<u64>,
550    /// Once shipped: timestamp of the most recent compaction run.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub last_run_at: Option<String>,
553    /// Once shipped: arbitrary JSON describing the most recent run.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub last_run_stats: Option<serde_json::Value>,
556}
557
558impl CapabilityCompaction {
559    /// Pre-v0.8 zero-state: planned, not enabled.
560    #[must_use]
561    pub fn planned() -> Self {
562        Self {
563            status: PlannedFeature::planned("v0.8+"),
564            interval_minutes: None,
565            last_run_at: None,
566            last_run_stats: None,
567        }
568    }
569}
570
571impl Default for CapabilityCompaction {
572    fn default() -> Self {
573        Self::planned()
574    }
575}
576
577/// Approval-API block (capabilities schema v2). `pending_requests`
578/// counts the existing `pending_actions` table (live signal).
579#[derive(Debug, Clone, Serialize, Deserialize, Default)]
580pub struct CapabilityApproval {
581    /// Live count of `pending_actions` with status='pending'.
582    pub pending_requests: usize,
583    // P1 honesty patch: `subscribers` (no subscription API exists) and
584    // `default_timeout_seconds` (no sweeper enforces timeouts) dropped
585    // from the v2 wire schema.
586}
587
588/// Sidechain-transcript block (capabilities schema v2). v0.7 Bucket 1.7
589/// work — reports `{planned, version, enabled}` until the subsystem
590/// ships. The honesty patch (P1) replaced the bare `enabled: false`
591/// with the planned-feature shape.
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct CapabilityTranscripts {
594    /// Planned-feature marker. `planned = true` while sidechain
595    /// transcripts live only on the roadmap.
596    #[serde(flatten)]
597    pub status: PlannedFeature,
598    /// Once shipped: number of stored transcripts.
599    #[serde(default, skip_serializing_if = "is_zero_usize")]
600    pub total_count: usize,
601    /// Once shipped: total transcript storage in megabytes.
602    #[serde(default, skip_serializing_if = "is_zero_u64")]
603    pub total_size_mb: u64,
604}
605
606impl CapabilityTranscripts {
607    /// Pre-v0.7 zero-state: planned, not enabled.
608    #[must_use]
609    pub fn planned() -> Self {
610        Self {
611            status: PlannedFeature::planned("v0.7+"),
612            total_count: 0,
613            total_size_mb: 0,
614        }
615    }
616}
617
618impl Default for CapabilityTranscripts {
619    fn default() -> Self {
620        Self::planned()
621    }
622}
623
624#[allow(clippy::trivially_copy_pass_by_ref)]
625fn is_zero_usize(n: &usize) -> bool {
626    *n == 0
627}
628
629#[allow(clippy::trivially_copy_pass_by_ref)]
630fn is_zero_u64(n: &u64) -> bool {
631    *n == 0
632}
633
634/// HNSW vector-index health (capabilities schema v2, v0.6.3.1 P3).
635///
636/// Closes the G2 audit gap by surfacing both the cumulative oldest-eviction
637/// count and a rolling-window flag so operators can distinguish "this
638/// process has hit the cap once, long ago" from "we are currently
639/// sustained at the cap and shedding embeddings now". Both numbers are
640/// process-local — the index itself resets on restart so persistence
641/// would be misleading.
642#[derive(Debug, Clone, Serialize, Deserialize, Default)]
643pub struct CapabilityHnsw {
644    /// Cumulative count of vectors evicted by the `MAX_ENTRIES`-cap path
645    /// since this process started.
646    pub evictions_total: u64,
647    /// True when at least one eviction has occurred in the last 60 s.
648    /// Lets dashboards alert on *active* pressure rather than only the
649    /// historical counter.
650    pub evicted_recently: bool,
651}
652
653// ---------------------------------------------------------------------------
654// Capabilities v1 — legacy shape retained for backward compat
655// ---------------------------------------------------------------------------
656
657/// Legacy (v1) capabilities shape — the structure shipped before the
658/// v0.6.3.1 honesty patch. Returned only when a client opts in via
659/// `Accept-Capabilities: v1` (HTTP) or the MCP `accept` argument set
660/// to `"v1"`. Default response is v2.
661///
662/// The v1 schema is frozen — do not extend it. New fields go into v2
663/// (see [`Capabilities`]).
664#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct CapabilitiesV1 {
666    pub tier: String,
667    pub version: String,
668    pub features: CapabilityFeaturesV1,
669    pub models: CapabilityModels,
670}
671
672/// Legacy v1 feature-flag block. Notably, `memory_reflection` is a
673/// `bool` here (it became a `PlannedFeature` object in v2).
674#[allow(clippy::struct_excessive_bools)]
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct CapabilityFeaturesV1 {
677    pub keyword_search: bool,
678    pub semantic_search: bool,
679    pub hybrid_recall: bool,
680    pub query_expansion: bool,
681    pub auto_consolidation: bool,
682    pub auto_tagging: bool,
683    pub contradiction_analysis: bool,
684    pub cross_encoder_reranking: bool,
685    pub memory_reflection: bool,
686    #[serde(default)]
687    pub embedder_loaded: bool,
688}
689
690impl Capabilities {
691    /// Project the v2 report down to the legacy v1 shape. Used to
692    /// honour `Accept-Capabilities: v1` from older clients.
693    ///
694    /// `memory_reflection` collapses from `{planned, enabled}` to a
695    /// single bool (`enabled` value). All v2-only fields
696    /// (`recall_mode_active`, `reranker_active`, `permissions`,
697    /// `hooks`, `compaction`, `approval`, `transcripts`) are dropped.
698    #[must_use]
699    pub fn to_v1(&self) -> CapabilitiesV1 {
700        CapabilitiesV1 {
701            tier: self.tier.clone(),
702            version: self.version.clone(),
703            features: CapabilityFeaturesV1 {
704                keyword_search: self.features.keyword_search,
705                semantic_search: self.features.semantic_search,
706                hybrid_recall: self.features.hybrid_recall,
707                query_expansion: self.features.query_expansion,
708                auto_consolidation: self.features.auto_consolidation,
709                auto_tagging: self.features.auto_tagging,
710                contradiction_analysis: self.features.contradiction_analysis,
711                cross_encoder_reranking: self.features.cross_encoder_reranking,
712                memory_reflection: self.features.memory_reflection.enabled,
713                embedder_loaded: self.features.embedder_loaded,
714            },
715            models: self.models.clone(),
716        }
717    }
718}
719
720// ---------------------------------------------------------------------------
721// TTL configuration
722// ---------------------------------------------------------------------------
723
724/// Per-tier TTL overrides loaded from `[ttl]` section of config.toml.
725#[allow(clippy::struct_field_names)]
726#[derive(Debug, Clone, Default, Serialize, Deserialize)]
727pub struct TtlConfig {
728    /// Short-tier default TTL in seconds (default: 21600 = 6 hours)
729    pub short_ttl_secs: Option<i64>,
730    /// Mid-tier default TTL in seconds (default: 604800 = 7 days)
731    pub mid_ttl_secs: Option<i64>,
732    /// Long-tier TTL in seconds (default: none = never expires). Set >0 to add expiry.
733    pub long_ttl_secs: Option<i64>,
734    /// Short-tier TTL extension on access in seconds (default: 3600 = 1 hour)
735    pub short_extend_secs: Option<i64>,
736    /// Mid-tier TTL extension on access in seconds (default: 86400 = 1 day)
737    pub mid_extend_secs: Option<i64>,
738}
739
740/// Resolved TTL values after merging config overrides with compiled defaults.
741#[derive(Debug, Clone)]
742#[allow(clippy::struct_field_names)]
743pub struct ResolvedTtl {
744    pub short_ttl_secs: Option<i64>,
745    pub mid_ttl_secs: Option<i64>,
746    pub long_ttl_secs: Option<i64>,
747    pub short_extend_secs: i64,
748    pub mid_extend_secs: i64,
749}
750
751impl Default for ResolvedTtl {
752    fn default() -> Self {
753        Self {
754            short_ttl_secs: Tier::Short.default_ttl_secs(),
755            mid_ttl_secs: Tier::Mid.default_ttl_secs(),
756            long_ttl_secs: Tier::Long.default_ttl_secs(),
757            short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
758            mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
759        }
760    }
761}
762
763/// Maximum configurable TTL: 10 years in seconds. Prevents integer overflow
764/// when adding Duration to `Utc::now()`.
765const MAX_TTL_SECS: i64 = 315_360_000;
766
767#[allow(dead_code)]
768impl ResolvedTtl {
769    /// Build from optional config overrides, falling back to compiled defaults.
770    /// TTL values are clamped to `MAX_TTL_SECS` (10 years) to prevent overflow.
771    /// Extension values are clamped to non-negative.
772    pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
773        let defaults = Self::default();
774        let Some(c) = cfg else {
775            return defaults;
776        };
777        let clamp_ttl = |v: i64| -> Option<i64> {
778            if v <= 0 {
779                None
780            } else {
781                Some(v.min(MAX_TTL_SECS))
782            }
783        };
784        Self {
785            short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
786            mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
787            long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
788            short_extend_secs: c
789                .short_extend_secs
790                .unwrap_or(defaults.short_extend_secs)
791                .max(0),
792            mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
793        }
794    }
795
796    /// Get the default TTL for a given tier.
797    pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
798        match tier {
799            Tier::Short => self.short_ttl_secs,
800            Tier::Mid => self.mid_ttl_secs,
801            Tier::Long => self.long_ttl_secs,
802        }
803    }
804
805    /// Get the TTL extension on access for a given tier.
806    pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
807        match tier {
808            Tier::Short => Some(self.short_extend_secs),
809            Tier::Mid => Some(self.mid_extend_secs),
810            Tier::Long => None,
811        }
812    }
813}
814
815// ---------------------------------------------------------------------------
816// Recall scoring (time-decay half-life) — v0.6.0.0
817// ---------------------------------------------------------------------------
818
819/// Per-tier half-life (days) overrides loaded from `[scoring]` section of
820/// `config.toml`.
821///
822/// The half-life is the number of days it takes for a memory's recall score
823/// to drop to 50% of its undecayed value. Shorter half-lives prioritize fresh
824/// memories; longer half-lives give older memories more weight. Defaults are
825/// chosen so each tier's decay curve matches its retention expectations:
826/// `short` memories decay quickly (7 d), `mid` moderately (30 d), `long`
827/// slowly (365 d).
828///
829/// Setting `legacy_scoring = true` disables the decay multiplier entirely,
830/// restoring the pre-v0.6.0.0 blended-score behavior for A/B comparison or
831/// if a recall-quality regression is reported.
832#[derive(Debug, Clone, Default, Serialize, Deserialize)]
833pub struct RecallScoringConfig {
834    /// Half-life for `short`-tier memories, in days (default 7).
835    pub half_life_days_short: Option<f64>,
836    /// Half-life for `mid`-tier memories, in days (default 30).
837    pub half_life_days_mid: Option<f64>,
838    /// Half-life for `long`-tier memories, in days (default 365).
839    pub half_life_days_long: Option<f64>,
840    /// When true, skip the decay multiplier entirely. Default false.
841    #[serde(default)]
842    pub legacy_scoring: bool,
843}
844
845/// Resolved scoring values after merging config overrides with compiled
846/// defaults. Half-lives are clamped to the range `[0.1, 36_500.0]` days
847/// (≈100 years) to keep the decay math well-behaved.
848#[derive(Debug, Clone, Copy)]
849pub struct ResolvedScoring {
850    pub half_life_days_short: f64,
851    pub half_life_days_mid: f64,
852    pub half_life_days_long: f64,
853    pub legacy_scoring: bool,
854}
855
856impl Default for ResolvedScoring {
857    fn default() -> Self {
858        Self {
859            half_life_days_short: 7.0,
860            half_life_days_mid: 30.0,
861            half_life_days_long: 365.0,
862            legacy_scoring: false,
863        }
864    }
865}
866
867impl ResolvedScoring {
868    const MIN_HALF_LIFE: f64 = 0.1;
869    const MAX_HALF_LIFE: f64 = 36_500.0;
870
871    /// Build from optional config overrides, falling back to compiled
872    /// defaults. Out-of-range values are silently clamped.
873    pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
874        let defaults = Self::default();
875        let Some(c) = cfg else {
876            return defaults;
877        };
878        let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
879        Self {
880            half_life_days_short: c
881                .half_life_days_short
882                .map_or(defaults.half_life_days_short, clamp),
883            half_life_days_mid: c
884                .half_life_days_mid
885                .map_or(defaults.half_life_days_mid, clamp),
886            half_life_days_long: c
887                .half_life_days_long
888                .map_or(defaults.half_life_days_long, clamp),
889            legacy_scoring: c.legacy_scoring,
890        }
891    }
892
893    /// Half-life in days for a given tier.
894    pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
895        match tier {
896            Tier::Short => self.half_life_days_short,
897            Tier::Mid => self.half_life_days_mid,
898            Tier::Long => self.half_life_days_long,
899        }
900    }
901
902    /// Compute the decay multiplier `exp(-ln(2) * age_days / half_life)`
903    /// for a memory of the given tier and age. Returns `1.0` when
904    /// `legacy_scoring` is true (no decay) or when `age_days` is non-positive
905    /// (future timestamps, clock skew, or new memories).
906    #[must_use]
907    pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
908        if self.legacy_scoring || age_days <= 0.0 {
909            return 1.0;
910        }
911        let half_life = self.half_life_for_tier(tier);
912        (-std::f64::consts::LN_2 * age_days / half_life).exp()
913    }
914}
915
916// ---------------------------------------------------------------------------
917// Persistent config file (~/.config/ai-memory/config.toml)
918// ---------------------------------------------------------------------------
919
920const CONFIG_DIR: &str = ".config/ai-memory";
921const CONFIG_FILE: &str = "config.toml";
922
923/// Persistent configuration loaded from `~/.config/ai-memory/config.toml`.
924///
925/// All fields are optional — CLI flags override file values, which override
926/// compiled defaults.
927#[derive(Debug, Clone, Default, Serialize, Deserialize)]
928pub struct AppConfig {
929    /// Feature tier: keyword, semantic, smart, autonomous
930    pub tier: Option<String>,
931    /// Path to the `SQLite` database file
932    pub db: Option<String>,
933    /// Ollama base URL for LLM generation (default: <http://localhost:11434>)
934    pub ollama_url: Option<String>,
935    /// Separate URL for embedding model (defaults to `ollama_url` if unset)
936    pub embed_url: Option<String>,
937    /// Embedding model override: `mini_lm_l6_v2` or `nomic_embed_v15`
938    pub embedding_model: Option<String>,
939    /// LLM model override (Ollama tag, e.g. "gemma4:e2b")
940    pub llm_model: Option<String>,
941    /// Enable cross-encoder reranking (true/false)
942    pub cross_encoder: Option<bool>,
943    /// Default namespace for new memories
944    pub default_namespace: Option<String>,
945    /// Maximum memory budget in MB (used for auto tier selection)
946    pub max_memory_mb: Option<usize>,
947    /// Per-tier TTL overrides
948    pub ttl: Option<TtlConfig>,
949    /// Archive memories before GC deletion (default: true)
950    pub archive_on_gc: Option<bool>,
951    /// Optional API key for HTTP API authentication
952    pub api_key: Option<String>,
953    /// Maximum archive age in days for automatic purge during GC (default: disabled)
954    pub archive_max_days: Option<i64>,
955    /// Identity-resolution overrides (Task 1.2 follow-up #198).
956    pub identity: Option<IdentityConfig>,
957    /// Recall scoring — per-tier half-life for time-decay, and `legacy_scoring`
958    /// kill switch (v0.6.0.0).
959    pub scoring: Option<RecallScoringConfig>,
960    /// v0.6.0.0: when true, fire LLM autonomy hooks (`auto_tag` +
961    /// `detect_contradiction`) synchronously on every successful
962    /// `memory_store`. Off by default — the hook blocks store latency
963    /// behind an Ollama round-trip. `AI_MEMORY_AUTONOMOUS_HOOKS=1`
964    /// env var overrides the config file.
965    pub autonomous_hooks: Option<bool>,
966    /// v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
967    /// Default-OFF for privacy; opt-in turns on the rolling file
968    /// appender that captures every `tracing::*` call site to disk.
969    pub logging: Option<LoggingConfig>,
970    /// v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF
971    /// for privacy; opt-in emits a hash-chained, tamper-evident JSON
972    /// log of every memory mutation suitable for SIEM ingestion and
973    /// SOC2 / HIPAA / GDPR / FedRAMP compliance evidence.
974    pub audit: Option<AuditConfig>,
975    /// v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy
976    /// kill-switch. Default-ON (existing users see no behavior change);
977    /// `[boot] enabled = false` silences boot entirely (empty stdout +
978    /// empty stderr, exit 0) for privacy-sensitive hosts where memory
979    /// titles must not enter CI logs. `[boot] redact_titles = true`
980    /// keeps the manifest header but replaces row titles with
981    /// `<redacted>` for compliance contexts that need the audit-trail
982    /// signal of "boot ran with N memories" without exposing subjects.
983    pub boot: Option<BootConfig>,
984    /// v0.6.4 — MCP server tunables. Today this only carries `profile`
985    /// (the named tool surface). Future v0.6.4 phases add the
986    /// `[mcp.allowlist]` per-agent capability table (Track D —
987    /// v0.6.4-008).
988    pub mcp: Option<McpConfig>,
989}
990
991// ---------------------------------------------------------------------------
992// Logging facility (PR-5)
993// ---------------------------------------------------------------------------
994
995/// `[logging]` block in `config.toml`. Every field is `Option`; missing
996/// fields fall back to the documented defaults.
997#[derive(Debug, Clone, Default, Serialize, Deserialize)]
998pub struct LoggingConfig {
999    /// Master toggle. Default `false`.
1000    pub enabled: Option<bool>,
1001    /// Directory for rotated logs. Default `~/.local/state/ai-memory/logs/`.
1002    pub path: Option<String>,
1003    /// Soft cap on a single rotated file (advisory — informs rotation
1004    /// configuration; the appender enforces this via the chosen
1005    /// `rotation` cadence). Default 100.
1006    pub max_size_mb: Option<u64>,
1007    /// Maximum number of rotated files retained on disk. Default 30.
1008    pub max_files: Option<usize>,
1009    /// Days of log history to keep before `ai-memory logs archive`
1010    /// would compress them. Default 90.
1011    pub retention_days: Option<u32>,
1012    /// Emit JSON lines instead of the human-readable fmt layer. Default `false`.
1013    pub structured: Option<bool>,
1014    /// Tracing level / `EnvFilter` directive. Default `"info"`.
1015    pub level: Option<String>,
1016    /// Rotation policy: `minutely | hourly | daily | never`. Default `"daily"`.
1017    pub rotation: Option<String>,
1018    /// Override the rotated-file prefix. Default `"ai-memory.log"`.
1019    pub filename_prefix: Option<String>,
1020}
1021
1022// ---------------------------------------------------------------------------
1023// Audit facility (PR-5)
1024// ---------------------------------------------------------------------------
1025
1026/// `[audit]` block in `config.toml`. Drives the hash-chained audit
1027/// trail emitted from every memory mutation call site.
1028#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1029pub struct AuditConfig {
1030    /// Master toggle. Default `false`.
1031    pub enabled: Option<bool>,
1032    /// Audit log path. Either a directory (in which case `audit.log`
1033    /// is appended) or an explicit file path. Default
1034    /// `~/.local/state/ai-memory/audit/`.
1035    pub path: Option<String>,
1036    /// Documented schema version on the wire. The binary always emits
1037    /// `audit::SCHEMA_VERSION`; this knob is reserved for forward
1038    /// compatibility and must equal the binary's emitted version
1039    /// today (validated at init).
1040    pub schema_version: Option<u32>,
1041    /// Whether to redact `memory.content` from emitted events. **The
1042    /// only supported value in v1 is `true`** — the audit schema does
1043    /// not expose a content field at all; this flag is reserved for a
1044    /// future per-namespace exception API.
1045    pub redact_content: Option<bool>,
1046    /// Whether to compute and verify the per-line hash chain. Default `true`.
1047    pub hash_chain: Option<bool>,
1048    /// Cadence in minutes for the periodic `CHECKPOINT.sig`
1049    /// attestation marker. The marker is a synthetic audit event that
1050    /// pins the chain head into the log so an attacker who truncates
1051    /// the file can't silently rewind history. Default 60. 0 disables.
1052    pub attestation_cadence_minutes: Option<u32>,
1053    /// Apply the platform-appropriate "append-only" file flag at
1054    /// startup. Best-effort defense in depth; the chain is the
1055    /// load-bearing tamper-evidence. Default `true`.
1056    pub append_only: Option<bool>,
1057    /// Retention horizon (days). `ai-memory logs purge` warns about
1058    /// deleting audit records younger than this, and `audit verify`
1059    /// surfaces gaps when retention is shorter than the chain extent.
1060    /// Default 90. Compliance presets override.
1061    pub retention_days: Option<u32>,
1062    /// Compliance presets — apply industry-standard retention /
1063    /// redaction policy on top of the base config. See
1064    /// `docs/security/audit-trail.md` §Compliance.
1065    pub compliance: Option<AuditComplianceConfig>,
1066}
1067
1068impl AuditConfig {
1069    /// Resolve the effective retention horizon after applying any
1070    /// active compliance preset. Presets win when `applied = true`;
1071    /// when multiple presets are applied the most-conservative
1072    /// (longest) retention wins so the binary never picks a value
1073    /// that violates any active policy.
1074    #[must_use]
1075    pub fn effective_retention_days(&self) -> u32 {
1076        let mut chosen = self.retention_days.unwrap_or(90);
1077        if let Some(comp) = &self.compliance {
1078            for preset in comp.applied_presets() {
1079                if let Some(d) = preset.retention_days
1080                    && d > chosen
1081                {
1082                    chosen = d;
1083                }
1084            }
1085        }
1086        chosen
1087    }
1088
1089    /// Resolve the effective attestation cadence — the most-frequent
1090    /// (smallest non-zero) cadence across the base config and applied
1091    /// presets so the strictest compliance rule wins.
1092    #[must_use]
1093    pub fn effective_attestation_cadence_minutes(&self) -> u32 {
1094        let base = self.attestation_cadence_minutes.unwrap_or(60);
1095        let mut chosen = base;
1096        if let Some(comp) = &self.compliance {
1097            for preset in comp.applied_presets() {
1098                if let Some(m) = preset.attestation_cadence_minutes
1099                    && m > 0
1100                    && (chosen == 0 || m < chosen)
1101                {
1102                    chosen = m;
1103                }
1104            }
1105        }
1106        chosen
1107    }
1108}
1109
1110// ---------------------------------------------------------------------------
1111// Boot privacy controls (PR-9h, v0.6.3.1, issue #487 PR #497 req #73)
1112// ---------------------------------------------------------------------------
1113
1114/// `[boot]` block in `config.toml`. Drives the privacy kill-switch +
1115/// title-redaction behaviour of `ai-memory boot`. Both fields default
1116/// to the historical (pre-v0.6.3.1) behaviour so existing users see no
1117/// change.
1118///
1119/// Precedence for `enabled`:
1120///   `AI_MEMORY_BOOT_ENABLED=0` env var (truthy "0/false/no/off") >
1121///   `[boot] enabled` config value > compiled default `true`.
1122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1123pub struct BootConfig {
1124    /// Master toggle. Default `true`. When set to `false`, `ai-memory
1125    /// boot` exits 0 with **empty stdout AND empty stderr** — the
1126    /// privacy-sensitive escape hatch for hosts where memory titles
1127    /// must never enter CI logs. The hook injects nothing.
1128    pub enabled: Option<bool>,
1129    /// When `true`, the manifest header still appears but every
1130    /// memory row's `title` field is replaced with `<redacted>` —
1131    /// useful for compliance contexts that need an audit trail of
1132    /// "boot ran with N memories" without exposing memory subjects.
1133    /// Default `false`.
1134    pub redact_titles: Option<bool>,
1135}
1136
1137impl BootConfig {
1138    /// Resolve the effective `enabled` value with env-var precedence.
1139    /// `AI_MEMORY_BOOT_ENABLED=0/false/no/off` forces disabled;
1140    /// `=1/true/yes/on` forces enabled. Anything else falls through to
1141    /// the config file value (or the compiled default `true`).
1142    #[must_use]
1143    pub fn effective_enabled(&self) -> bool {
1144        if let Ok(v) = std::env::var("AI_MEMORY_BOOT_ENABLED") {
1145            let v = v.trim().to_ascii_lowercase();
1146            if matches!(v.as_str(), "0" | "false" | "no" | "off") {
1147                return false;
1148            }
1149            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
1150                return true;
1151            }
1152        }
1153        self.enabled.unwrap_or(true)
1154    }
1155
1156    /// Resolve the effective `redact_titles` value. Default `false`.
1157    #[must_use]
1158    pub fn effective_redact_titles(&self) -> bool {
1159        self.redact_titles.unwrap_or(false)
1160    }
1161}
1162
1163// ---------------------------------------------------------------------------
1164// MCP server tunables (v0.6.4)
1165// ---------------------------------------------------------------------------
1166
1167/// `[mcp]` block in `config.toml` — v0.6.4 addition. Today this only
1168/// carries the named tool `profile`. v0.6.4 Track D will extend with
1169/// `[mcp.allowlist]` for per-agent capability gating.
1170///
1171/// Resolution for `profile`: CLI flag > `AI_MEMORY_PROFILE` env (both
1172/// merged by clap) > this config field > compiled default `"core"`.
1173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1174pub struct McpConfig {
1175    /// Named tool profile. One of `core`, `graph`, `admin`, `power`,
1176    /// `full`, or a comma-separated custom list (e.g.,
1177    /// `core,graph,archive`). Default `core` (v0.6.4 default flip).
1178    pub profile: Option<String>,
1179
1180    /// v0.6.4-008 — per-agent capability allowlist. Maps an agent_id
1181    /// pattern to the families that agent may request via
1182    /// `memory_capabilities --include-schema family=<f>`. Patterns
1183    /// resolve to a Vec<String> (the family names). The wildcard
1184    /// pattern `"*"` is the default for agents not otherwise listed.
1185    /// When the entire allowlist is absent (`mcp.allowlist = None`),
1186    /// the gate is disabled — every caller may expand any family
1187    /// (Tier-1 single-process semantics, profile flag rules).
1188    ///
1189    /// Example config.toml:
1190    /// ```toml
1191    /// [mcp.allowlist]
1192    /// "alice" = ["core", "graph"]
1193    /// "bob"   = ["full"]
1194    /// "*"     = ["core"]
1195    /// ```
1196    pub allowlist: Option<std::collections::HashMap<String, Vec<String>>>,
1197}
1198
1199impl McpConfig {
1200    /// v0.6.4-008 — resolve the allowlist decision for an agent
1201    /// requesting a family.
1202    ///
1203    /// Returns:
1204    /// - `AllowlistDecision::Disabled` if the entire allowlist is
1205    ///   absent (Tier-1 default — gate is off).
1206    /// - `AllowlistDecision::Allow` if a matching pattern includes
1207    ///   the requested family (or `"full"`).
1208    /// - `AllowlistDecision::Deny` if a pattern matches but does
1209    ///   not list the family.
1210    /// - `AllowlistDecision::Deny` if no pattern matches and there
1211    ///   is no `"*"` wildcard.
1212    ///
1213    /// Pattern matching: exact match wins; otherwise the wildcard
1214    /// `"*"` is consulted. Multiple-pattern precedence follows
1215    /// longest-prefix order with stable tie-break by config order
1216    /// (since `HashMap` is unordered, we sort by key length
1217    /// descending for the comparison).
1218    #[must_use]
1219    pub fn allowlist_decision(&self, agent_id: Option<&str>, family: &str) -> AllowlistDecision {
1220        let table = match self.allowlist.as_ref() {
1221            Some(t) if !t.is_empty() => t,
1222            _ => return AllowlistDecision::Disabled,
1223        };
1224        // Tier-1: no agent_id → only the wildcard rule applies. Same
1225        // restrictive default as for an unknown agent.
1226        let aid = agent_id.unwrap_or("");
1227        // Exact match first.
1228        if let Some(families) = table.get(aid) {
1229            return decide(families, family);
1230        }
1231        // Longest-prefix match next (excluding `"*"`).
1232        let mut keys: Vec<&String> = table
1233            .keys()
1234            .filter(|k| k.as_str() != "*" && aid.starts_with(k.as_str()))
1235            .collect();
1236        keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
1237        if let Some(k) = keys.first() {
1238            if let Some(families) = table.get(*k) {
1239                return decide(families, family);
1240            }
1241        }
1242        // Wildcard fallback.
1243        if let Some(families) = table.get("*") {
1244            return decide(families, family);
1245        }
1246        AllowlistDecision::Deny
1247    }
1248}
1249
1250fn decide(families: &[String], requested: &str) -> AllowlistDecision {
1251    if families.iter().any(|f| f == "full" || f == requested) {
1252        AllowlistDecision::Allow
1253    } else {
1254        AllowlistDecision::Deny
1255    }
1256}
1257
1258/// v0.6.4-008 — outcome of an allowlist check.
1259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1260pub enum AllowlistDecision {
1261    /// Allowlist is not configured; no gate.
1262    Disabled,
1263    /// Pattern match grants access to the requested family.
1264    Allow,
1265    /// Pattern match denies (or no pattern matched).
1266    Deny,
1267}
1268
1269#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1270pub struct AuditComplianceConfig {
1271    pub soc2: Option<CompliancePreset>,
1272    pub hipaa: Option<CompliancePreset>,
1273    pub gdpr: Option<CompliancePreset>,
1274    pub fedramp: Option<CompliancePreset>,
1275}
1276
1277impl AuditComplianceConfig {
1278    /// Iterate over every preset whose `applied = true`.
1279    pub fn applied_presets(&self) -> impl Iterator<Item = &CompliancePreset> {
1280        [
1281            self.soc2.as_ref(),
1282            self.hipaa.as_ref(),
1283            self.gdpr.as_ref(),
1284            self.fedramp.as_ref(),
1285        ]
1286        .into_iter()
1287        .flatten()
1288        .filter(|p| p.applied.unwrap_or(false))
1289    }
1290}
1291
1292#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1293pub struct CompliancePreset {
1294    pub applied: Option<bool>,
1295    pub retention_days: Option<u32>,
1296    pub redact_content: Option<bool>,
1297    pub attestation_cadence_minutes: Option<u32>,
1298    /// Reserved for compliance contexts that mandate at-rest crypto.
1299    /// HIPAA preset surfaces this so operators can pair audit with
1300    /// `--features sqlcipher` for end-to-end at-rest encryption.
1301    pub encrypt_at_rest: Option<bool>,
1302    /// GDPR-style actor pseudonymization toggle. Reserved for v0.7+.
1303    pub pseudonymize_actors: Option<bool>,
1304}
1305
1306/// Identity-resolution configuration (Task 1.2 follow-up #198).
1307///
1308/// Lets operators opt out of the default `host:<hostname>:pid-<pid>-<uuid8>`
1309/// fallback when no explicit `agent_id` is supplied. `anonymize_default = true`
1310/// swaps the hostname-revealing default for `anonymous:pid-<pid>-<uuid8>`,
1311/// matching what the `AI_MEMORY_ANONYMIZE=1` env var does ephemerally.
1312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1313pub struct IdentityConfig {
1314    /// When true, the "no flag, no env, no MCP clientInfo" fallback uses
1315    /// `anonymous:pid-<pid>-<uuid8>` instead of the hostname-revealing
1316    /// `host:<hostname>:pid-<pid>-<uuid8>`. Default false.
1317    #[serde(default)]
1318    pub anonymize_default: bool,
1319}
1320
1321impl AppConfig {
1322    /// Returns the config file path: `~/.config/ai-memory/config.toml`
1323    pub fn config_path() -> Option<PathBuf> {
1324        let home = std::env::var("HOME").ok()?;
1325        Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
1326    }
1327
1328    /// Load config from disk. Returns `AppConfig::default()` if file is missing.
1329    /// Set `AI_MEMORY_NO_CONFIG=1` to skip config loading (used by integration tests).
1330    pub fn load() -> Self {
1331        if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
1332            return Self::default();
1333        }
1334        let Some(path) = Self::config_path() else {
1335            return Self::default();
1336        };
1337        Self::load_from(&path)
1338    }
1339
1340    /// Load config from a specific path.
1341    pub fn load_from(path: &Path) -> Self {
1342        match std::fs::read_to_string(path) {
1343            Ok(contents) => match toml::from_str(&contents) {
1344                Ok(cfg) => {
1345                    eprintln!("ai-memory: loaded config from {}", path.display());
1346                    cfg
1347                }
1348                Err(e) => {
1349                    eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
1350                    Self::default()
1351                }
1352            },
1353            Err(_) => Self::default(),
1354        }
1355    }
1356
1357    /// Resolve the effective feature tier from config (CLI flag overrides).
1358    pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
1359        let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
1360        FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
1361    }
1362
1363    /// Resolve the effective database path (CLI flag overrides config).
1364    pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
1365        // If CLI provided a non-default path, use it
1366        let default_db = PathBuf::from("ai-memory.db");
1367        if cli_db != default_db {
1368            return cli_db.to_path_buf();
1369        }
1370        // Otherwise check config
1371        self.db
1372            .as_ref()
1373            .map_or_else(|| cli_db.to_path_buf(), PathBuf::from)
1374    }
1375
1376    /// Resolve Ollama URL for LLM generation (config or default).
1377    pub fn effective_ollama_url(&self) -> &str {
1378        self.ollama_url
1379            .as_deref()
1380            .unwrap_or("http://localhost:11434")
1381    }
1382
1383    /// Resolve TTL configuration from config file, falling back to compiled defaults.
1384    pub fn effective_ttl(&self) -> ResolvedTtl {
1385        ResolvedTtl::from_config(self.ttl.as_ref())
1386    }
1387
1388    /// Resolve recall-scoring configuration (time-decay half-life) from the
1389    /// config file, falling back to compiled defaults. v0.6.0.0.
1390    pub fn effective_scoring(&self) -> ResolvedScoring {
1391        ResolvedScoring::from_config(self.scoring.as_ref())
1392    }
1393
1394    /// Whether to archive memories before GC deletion (default: true).
1395    pub fn effective_archive_on_gc(&self) -> bool {
1396        self.archive_on_gc.unwrap_or(true)
1397    }
1398
1399    /// v0.6.4-001 — resolve the effective MCP tool profile.
1400    ///
1401    /// Resolution order:
1402    /// 1. `cli_or_env` (already merged by clap's `#[arg(env="AI_MEMORY_PROFILE")]`)
1403    /// 2. `[mcp].profile` config field
1404    /// 3. compiled default `"core"`
1405    ///
1406    /// # Errors
1407    ///
1408    /// Returns [`crate::profile::ProfileParseError`] if any layer's
1409    /// value is malformed (unknown family or mixed-case token).
1410    pub fn effective_profile(
1411        &self,
1412        cli_or_env: Option<&str>,
1413    ) -> Result<crate::profile::Profile, crate::profile::ProfileParseError> {
1414        let raw = cli_or_env
1415            .or_else(|| self.mcp.as_ref().and_then(|m| m.profile.as_deref()))
1416            .unwrap_or("core");
1417        crate::profile::Profile::parse(raw)
1418    }
1419
1420    /// Whether post-store autonomy hooks (`auto_tag` + `detect_contradiction`)
1421    /// fire on every successful `memory_store`. v0.6.0.0.
1422    /// Precedence: `AI_MEMORY_AUTONOMOUS_HOOKS=1` env var (truthy) >
1423    /// config file > default false. `AI_MEMORY_AUTONOMOUS_HOOKS=0` also
1424    /// honored for explicit-off.
1425    pub fn effective_autonomous_hooks(&self) -> bool {
1426        if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
1427            let v = v.trim().to_ascii_lowercase();
1428            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
1429                return true;
1430            }
1431            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
1432                return false;
1433            }
1434        }
1435        self.autonomous_hooks.unwrap_or(false)
1436    }
1437
1438    /// Whether to anonymize the default `agent_id` fallback (Task 1.2 #198).
1439    /// Precedence: `AI_MEMORY_ANONYMIZE=1` env var (truthy) > config file > default false.
1440    pub fn effective_anonymize_default(&self) -> bool {
1441        if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
1442            let v = v.trim().to_ascii_lowercase();
1443            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
1444                return true;
1445            }
1446            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
1447                return false;
1448            }
1449        }
1450        self.identity.as_ref().is_some_and(|i| i.anonymize_default)
1451    }
1452
1453    /// Resolve the [`LoggingConfig`] block, returning a default
1454    /// (disabled) instance when the config file omits it.
1455    pub fn effective_logging(&self) -> LoggingConfig {
1456        self.logging.clone().unwrap_or_default()
1457    }
1458
1459    /// Resolve the [`AuditConfig`] block, returning a default
1460    /// (disabled) instance when the config file omits it.
1461    pub fn effective_audit(&self) -> AuditConfig {
1462        self.audit.clone().unwrap_or_default()
1463    }
1464
1465    /// Resolve the [`BootConfig`] block, returning a default
1466    /// (enabled, no redaction) instance when the config file omits
1467    /// it. v0.6.3.1 (PR-9h / issue #487 PR #497 req #73).
1468    pub fn effective_boot(&self) -> BootConfig {
1469        self.boot.clone().unwrap_or_default()
1470    }
1471
1472    /// Resolve URL for embedding model (falls back to `ollama_url`).
1473    pub fn effective_embed_url(&self) -> &str {
1474        self.embed_url
1475            .as_deref()
1476            .or(self.ollama_url.as_deref())
1477            .unwrap_or("http://localhost:11434")
1478    }
1479
1480    /// Write a default config file if one doesn't exist yet.
1481    pub fn write_default_if_missing() {
1482        let Some(path) = Self::config_path() else {
1483            return;
1484        };
1485        if path.exists() {
1486            return;
1487        }
1488        if let Some(parent) = path.parent() {
1489            let _ = std::fs::create_dir_all(parent);
1490        }
1491        let default_toml = r#"# ai-memory configuration
1492# See: https://github.com/alphaonedev/ai-memory-mcp
1493
1494# Feature tier: keyword, semantic, smart, autonomous
1495# tier = "semantic"
1496
1497# Path to SQLite database
1498# db = "~/.claude/ai-memory.db"
1499
1500# Ollama base URL (for smart/autonomous tiers)
1501# ollama_url = "http://localhost:11434"
1502
1503# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
1504# embedding_model = "mini_lm_l6_v2"
1505
1506# LLM model tag for Ollama
1507# llm_model = "gemma4:e2b"
1508
1509# Enable neural cross-encoder reranking (autonomous tier)
1510# cross_encoder = true
1511
1512# Default namespace for new memories
1513# default_namespace = "global"
1514
1515# Memory budget in MB (for auto tier selection)
1516# max_memory_mb = 4096
1517
1518# Archive expired memories before GC deletion (default: true)
1519# archive_on_gc = true
1520
1521# Per-tier TTL overrides (uncomment to customize)
1522# [ttl]
1523# short_ttl_secs = 21600        # 6 hours (default)
1524# mid_ttl_secs = 604800         # 7 days (default)
1525# long_ttl_secs = 0             # 0 = never expires (default)
1526# short_extend_secs = 3600      # +1h on access (default)
1527# mid_extend_secs = 86400       # +1d on access (default)
1528
1529# v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
1530# Default-OFF. Uncomment + set enabled = true to capture every
1531# `tracing::*` call site to a rotating on-disk log file. See
1532# `docs/security/audit-trail.md` §SIEM ingestion guide for Splunk /
1533# Datadog / Elastic / Loki recipes.
1534# [logging]
1535# enabled = false
1536# path = "~/.local/state/ai-memory/logs/"
1537# max_size_mb = 100
1538# max_files = 30
1539# retention_days = 90
1540# structured = false              # true = emit JSON lines for SIEM ingest
1541# level = "info"                  # tracing EnvFilter directive
1542# rotation = "daily"              # minutely | hourly | daily | never
1543
1544# v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF.
1545# When enabled, every memory mutation emits one hash-chained JSON
1546# line per event suitable for SOC2 / HIPAA / GDPR / FedRAMP evidence.
1547# `ai-memory audit verify` walks the chain; `ai-memory logs tail`
1548# streams events.
1549# [audit]
1550# enabled = false
1551# path = "~/.local/state/ai-memory/audit/"
1552# schema_version = 1
1553# redact_content = true            # v1 schema never emits content; reserved
1554# hash_chain = true
1555# attestation_cadence_minutes = 60
1556# append_only = true               # best-effort chflags(2) / FS_IOC_SETFLAGS
1557
1558# Compliance presets. Set `applied = true` and the documented retention
1559# / cadence values override the defaults above. See
1560# `docs/security/audit-trail.md` §Compliance.
1561# [audit.compliance.soc2]
1562# applied = false
1563# retention_days = 730
1564# redact_content = true
1565# attestation_cadence_minutes = 60
1566#
1567# [audit.compliance.hipaa]
1568# applied = false
1569# retention_days = 2190
1570# redact_content = true
1571# encrypt_at_rest = true           # pair with --features sqlcipher
1572#
1573# [audit.compliance.gdpr]
1574# applied = false
1575# retention_days = 1095
1576# redact_content = true
1577# pseudonymize_actors = true       # reserved for v0.7+
1578#
1579# [audit.compliance.fedramp]
1580# applied = false
1581# retention_days = 1095
1582# redact_content = true
1583# attestation_cadence_minutes = 30
1584
1585# v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy controls.
1586# Default-ON (omit the section entirely for the historical pre-v0.6.3.1
1587# behavior). Two knobs:
1588#
1589# - `enabled = false` silences `ai-memory boot` entirely: empty stdout,
1590#   empty stderr, exit 0. The SessionStart hook injects nothing. Use on
1591#   privacy-sensitive hosts where memory titles must never enter CI
1592#   logs. The env var `AI_MEMORY_BOOT_ENABLED=0` takes precedence over
1593#   this config (same precedence pattern as PR-5's log-dir resolution).
1594#
1595# - `redact_titles = true` keeps the manifest header but replaces row
1596#   `title` fields with `<redacted>` — useful for compliance contexts
1597#   that need the audit-trail signal of "boot ran with N memories"
1598#   without exposing memory subjects.
1599# [boot]
1600# enabled = true
1601# redact_titles = false
1602"#;
1603        let _ = std::fs::write(&path, default_toml);
1604    }
1605}
1606
1607// ---------------------------------------------------------------------------
1608// Tests
1609// ---------------------------------------------------------------------------
1610
1611#[cfg(test)]
1612mod tests {
1613    use super::*;
1614
1615    #[test]
1616    fn tier_roundtrip() {
1617        for tier in [
1618            FeatureTier::Keyword,
1619            FeatureTier::Semantic,
1620            FeatureTier::Smart,
1621            FeatureTier::Autonomous,
1622        ] {
1623            assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
1624        }
1625    }
1626
1627    #[test]
1628    fn budget_selection() {
1629        assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
1630        assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
1631        assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
1632        assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
1633        assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
1634        assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
1635        assert_eq!(
1636            FeatureTier::from_memory_budget(4096),
1637            FeatureTier::Autonomous
1638        );
1639        assert_eq!(
1640            FeatureTier::from_memory_budget(8192),
1641            FeatureTier::Autonomous
1642        );
1643    }
1644
1645    #[test]
1646    fn embedding_dimensions() {
1647        assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
1648        assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
1649    }
1650
1651    #[test]
1652    fn autonomous_has_cross_encoder() {
1653        let cfg = FeatureTier::Autonomous.config();
1654        assert!(cfg.cross_encoder);
1655        let caps = cfg.capabilities();
1656        assert!(caps.features.cross_encoder_reranking);
1657        // P1 honesty patch: memory_reflection is a planned-feature
1658        // object now. Even on the autonomous tier the underlying
1659        // subsystem is roadmap (v0.7+), so `planned == true` and
1660        // `enabled == false` regardless of tier.
1661        assert!(caps.features.memory_reflection.planned);
1662        assert!(!caps.features.memory_reflection.enabled);
1663        assert_eq!(caps.features.memory_reflection.version, "v0.7+");
1664    }
1665
1666    #[test]
1667    fn keyword_has_no_models() {
1668        let cfg = FeatureTier::Keyword.config();
1669        assert!(cfg.embedding_model.is_none());
1670        assert!(cfg.llm_model.is_none());
1671        assert!(!cfg.cross_encoder);
1672        assert_eq!(cfg.max_memory_mb, 0);
1673    }
1674
1675    #[test]
1676    fn capabilities_serialize() {
1677        let caps = FeatureTier::Smart.config().capabilities();
1678        let json = serde_json::to_string_pretty(&caps).unwrap();
1679        assert!(json.contains("\"tier\": \"smart\""));
1680        assert!(json.contains("nomic"));
1681        assert!(json.contains("gemma4:e2b"));
1682    }
1683
1684    /// v0.6.3.1 (capabilities schema v2, P1 honesty patch).
1685    /// Round-trip the new struct through serde_json and assert the v2
1686    /// honesty contract: dropped fields absent, planned-feature blocks
1687    /// shaped correctly, runtime-state defaults conservative.
1688    #[test]
1689    fn capabilities_v2_zero_state_round_trip() {
1690        let caps = FeatureTier::Keyword.config().capabilities();
1691        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
1692
1693        assert_eq!(val["schema_version"], "2");
1694
1695        // permissions zero-state: mode="advisory" (was "ask" in v1),
1696        // active_rules=0. `rule_summary` dropped from v2.
1697        assert_eq!(val["permissions"]["mode"], "advisory");
1698        assert_eq!(val["permissions"]["active_rules"], 0);
1699        assert!(
1700            val["permissions"].get("rule_summary").is_none(),
1701            "v2 honesty patch drops `permissions.rule_summary` (no per-rule serializer)"
1702        );
1703        // v0.6.3.1 (P4, audit G1): inheritance posture surfaced.
1704        assert_eq!(val["permissions"]["inheritance"], "enforced");
1705
1706        // hooks zero-state: 0 registered. `by_event` dropped from v2.
1707        assert_eq!(val["hooks"]["registered_count"], 0);
1708        assert!(
1709            val["hooks"].get("by_event").is_none(),
1710            "v2 honesty patch drops `hooks.by_event` (no event registry)"
1711        );
1712
1713        // hooks zero-state: 0 registered, by_event dropped (P1 honesty)
1714        assert_eq!(val["hooks"]["registered_count"], 0);
1715        assert!(
1716            val["hooks"].get("by_event").is_none(),
1717            "v2 drops hooks.by_event (no event registry)"
1718        );
1719        // P5 (G9): webhook_events must always surface the canonical
1720        // five lifecycle events so integrators can pin a subscribe
1721        // filter against them.
1722        let events = val["hooks"]["webhook_events"].as_array().unwrap();
1723        assert_eq!(events.len(), 5);
1724        for expected in [
1725            "memory_store",
1726            "memory_promote",
1727            "memory_delete",
1728            "memory_link_created",
1729            "memory_consolidated",
1730        ] {
1731            assert!(
1732                events.iter().any(|v| v.as_str() == Some(expected)),
1733                "webhook_events missing {expected}"
1734            );
1735        }
1736
1737        // compaction zero-state: planned, not enabled, optional fields omitted
1738        assert_eq!(val["compaction"]["planned"], true);
1739        assert_eq!(val["compaction"]["enabled"], false);
1740        assert_eq!(val["compaction"]["version"], "v0.8+");
1741        assert!(
1742            val["compaction"].get("interval_minutes").is_none(),
1743            "Option::None values must be skipped in serialization"
1744        );
1745        assert!(val["compaction"].get("last_run_at").is_none());
1746        assert!(val["compaction"].get("last_run_stats").is_none());
1747
1748        // approval zero-state: 0 pending. `subscribers` and
1749        // `default_timeout_seconds` dropped from v2.
1750        assert_eq!(val["approval"]["pending_requests"], 0);
1751        assert!(
1752            val["approval"].get("subscribers").is_none(),
1753            "v2 honesty patch drops `approval.subscribers` (no subscription API)"
1754        );
1755        assert!(
1756            val["approval"].get("default_timeout_seconds").is_none(),
1757            "v2 honesty patch drops `approval.default_timeout_seconds` (no sweeper)"
1758        );
1759
1760        // transcripts zero-state: planned, not enabled, zero counts skipped
1761        assert_eq!(val["transcripts"]["planned"], true);
1762        assert_eq!(val["transcripts"]["enabled"], false);
1763        assert_eq!(val["transcripts"]["version"], "v0.7+");
1764
1765        // memory_reflection: planned-feature object (was bool)
1766        assert_eq!(val["features"]["memory_reflection"]["planned"], true);
1767        assert_eq!(val["features"]["memory_reflection"]["enabled"], false);
1768        assert_eq!(val["features"]["memory_reflection"]["version"], "v0.7+");
1769
1770        // Runtime-state defaults are conservative — they get overlaid
1771        // at the handler boundary based on the live embedder + reranker
1772        // handles. With no overlays, the keyword-tier daemon reports
1773        // `disabled` / `off`.
1774        assert_eq!(val["features"]["recall_mode_active"], "disabled");
1775        assert_eq!(val["features"]["reranker_active"], "off");
1776
1777        // Round-trip back to a typed Capabilities and confirm field
1778        // identity (proves Deserialize works for all reshaped structs).
1779        let restored: Capabilities = serde_json::from_value(val).unwrap();
1780        assert_eq!(restored.schema_version, "2");
1781        assert_eq!(restored.permissions.mode, "advisory");
1782        assert!(restored.compaction.status.planned);
1783        assert!(restored.transcripts.status.planned);
1784        assert_eq!(restored.features.recall_mode_active, RecallMode::Disabled);
1785        assert_eq!(restored.features.reranker_active, RerankerMode::Off);
1786    }
1787
1788    /// P1 honesty patch: legacy v1 projection preserves the old shape
1789    /// for clients that opt in via `Accept-Capabilities: v1`.
1790    #[test]
1791    fn capabilities_v1_projection_preserves_legacy_shape() {
1792        let caps = FeatureTier::Autonomous.config().capabilities();
1793        let v1 = caps.to_v1();
1794        let val: serde_json::Value = serde_json::to_value(&v1).unwrap();
1795
1796        // v1: no schema_version, no v2-only blocks
1797        assert!(
1798            val.get("schema_version").is_none(),
1799            "v1 has no schema_version"
1800        );
1801        assert!(
1802            val.get("permissions").is_none(),
1803            "v1 has no permissions block"
1804        );
1805        assert!(val.get("hooks").is_none());
1806        assert!(val.get("compaction").is_none());
1807        assert!(val.get("approval").is_none());
1808        assert!(val.get("transcripts").is_none());
1809
1810        // v1 keeps the four legacy top-level keys
1811        assert!(val["tier"].is_string());
1812        assert!(val["version"].is_string());
1813        assert!(val["features"].is_object());
1814        assert!(val["models"].is_object());
1815
1816        // v1 features.memory_reflection collapses to a bool — autonomous
1817        // tier had cross_encoder + has_llm but the planned object's
1818        // `enabled = false`, so the v1 bool is `false`.
1819        assert!(val["features"]["memory_reflection"].is_boolean());
1820        assert_eq!(val["features"]["memory_reflection"], false);
1821
1822        // v1 features carry no recall_mode_active / reranker_active
1823        assert!(val["features"].get("recall_mode_active").is_none());
1824        assert!(val["features"].get("reranker_active").is_none());
1825    }
1826
1827    #[test]
1828    fn config_default_is_empty() {
1829        let cfg = AppConfig::default();
1830        assert!(cfg.tier.is_none());
1831        assert!(cfg.db.is_none());
1832        assert!(cfg.ollama_url.is_none());
1833    }
1834
1835    #[test]
1836    fn config_parse_toml() {
1837        let toml_str = r#"
1838            tier = "smart"
1839            db = "/tmp/test.db"
1840            ollama_url = "http://localhost:11434"
1841            cross_encoder = true
1842        "#;
1843        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
1844        assert_eq!(cfg.tier.as_deref(), Some("smart"));
1845        assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
1846        assert!(cfg.cross_encoder.unwrap());
1847    }
1848
1849    #[test]
1850    fn resolved_ttl_defaults_match_hardcoded() {
1851        let resolved = ResolvedTtl::default();
1852        assert_eq!(resolved.short_ttl_secs, Some(6 * 3600));
1853        assert_eq!(resolved.mid_ttl_secs, Some(7 * 24 * 3600));
1854        assert_eq!(resolved.long_ttl_secs, None);
1855        assert_eq!(resolved.short_extend_secs, 3600);
1856        assert_eq!(resolved.mid_extend_secs, 86400);
1857    }
1858
1859    #[test]
1860    fn resolved_ttl_from_partial_config() {
1861        let cfg = TtlConfig {
1862            mid_ttl_secs: Some(90 * 24 * 3600), // ~3 months
1863            ..Default::default()
1864        };
1865        let resolved = ResolvedTtl::from_config(Some(&cfg));
1866        assert_eq!(resolved.short_ttl_secs, Some(6 * 3600)); // unchanged
1867        assert_eq!(resolved.mid_ttl_secs, Some(90 * 24 * 3600)); // overridden
1868        assert_eq!(resolved.long_ttl_secs, None); // unchanged
1869    }
1870
1871    #[test]
1872    fn resolved_ttl_zero_means_no_expiry() {
1873        let cfg = TtlConfig {
1874            short_ttl_secs: Some(0),
1875            mid_ttl_secs: Some(0),
1876            ..Default::default()
1877        };
1878        let resolved = ResolvedTtl::from_config(Some(&cfg));
1879        assert_eq!(resolved.short_ttl_secs, None); // 0 → no expiry
1880        assert_eq!(resolved.mid_ttl_secs, None);
1881    }
1882
1883    #[test]
1884    fn resolved_ttl_clamps_overflow() {
1885        let cfg = TtlConfig {
1886            mid_ttl_secs: Some(i64::MAX),
1887            short_extend_secs: Some(-3600),
1888            ..Default::default()
1889        };
1890        let resolved = ResolvedTtl::from_config(Some(&cfg));
1891        // i64::MAX should be clamped to MAX_TTL_SECS (10 years)
1892        assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
1893        // negative extend should be clamped to 0
1894        assert_eq!(resolved.short_extend_secs, 0);
1895    }
1896
1897    #[test]
1898    fn ttl_config_parse_toml() {
1899        let toml_str = r#"
1900            tier = "semantic"
1901            archive_on_gc = false
1902            [ttl]
1903            mid_ttl_secs = 7776000
1904            short_extend_secs = 7200
1905        "#;
1906        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
1907        assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
1908        assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
1909        assert!(!cfg.effective_archive_on_gc());
1910    }
1911
1912    #[test]
1913    fn resolved_ttl_tier_methods() {
1914        let resolved = ResolvedTtl::default();
1915        assert_eq!(resolved.ttl_for_tier(&Tier::Short), Some(6 * 3600));
1916        assert_eq!(resolved.ttl_for_tier(&Tier::Mid), Some(7 * 24 * 3600));
1917        assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
1918        assert_eq!(resolved.extend_for_tier(&Tier::Short), Some(3600));
1919        assert_eq!(resolved.extend_for_tier(&Tier::Mid), Some(86400));
1920        assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
1921    }
1922
1923    #[test]
1924    fn config_effective_tier() {
1925        let cfg = AppConfig {
1926            tier: Some("smart".to_string()),
1927            ..Default::default()
1928        };
1929        // CLI override wins
1930        assert_eq!(
1931            cfg.effective_tier(Some("autonomous")),
1932            FeatureTier::Autonomous
1933        );
1934        // Config value used when no CLI
1935        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
1936    }
1937
1938    // --- v0.6.0.0 recall scoring (time-decay half-life) ---
1939
1940    #[test]
1941    fn scoring_defaults_match_spec() {
1942        let s = ResolvedScoring::default();
1943        assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
1944        assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
1945        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
1946        assert!(!s.legacy_scoring);
1947    }
1948
1949    #[test]
1950    fn scoring_from_config_overrides() {
1951        let cfg = RecallScoringConfig {
1952            half_life_days_short: Some(3.5),
1953            half_life_days_mid: Some(14.0),
1954            half_life_days_long: Some(730.0),
1955            legacy_scoring: false,
1956        };
1957        let s = ResolvedScoring::from_config(Some(&cfg));
1958        assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
1959        assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
1960        assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
1961    }
1962
1963    #[test]
1964    fn scoring_clamps_out_of_range() {
1965        let cfg = RecallScoringConfig {
1966            half_life_days_short: Some(-10.0),
1967            half_life_days_mid: Some(0.0),
1968            half_life_days_long: Some(1_000_000.0),
1969            legacy_scoring: false,
1970        };
1971        let s = ResolvedScoring::from_config(Some(&cfg));
1972        assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
1973        assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
1974        assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
1975    }
1976
1977    #[test]
1978    fn scoring_decay_at_half_life_is_half() {
1979        let s = ResolvedScoring::default();
1980        // Short tier half-life is 7 days → at age=7d, decay=0.5
1981        let d = s.decay_multiplier(&Tier::Short, 7.0);
1982        assert!((d - 0.5).abs() < 1e-9);
1983        let d = s.decay_multiplier(&Tier::Mid, 30.0);
1984        assert!((d - 0.5).abs() < 1e-9);
1985        let d = s.decay_multiplier(&Tier::Long, 365.0);
1986        assert!((d - 0.5).abs() < 1e-9);
1987    }
1988
1989    #[test]
1990    fn scoring_decay_monotonic() {
1991        let s = ResolvedScoring::default();
1992        let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
1993        let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
1994        // Older memories decay more (lower multiplier).
1995        assert!(d_new > d_old);
1996        assert!(d_new < 1.0);
1997        assert!(d_old > 0.0);
1998    }
1999
2000    #[test]
2001    fn scoring_decay_zero_age_is_one() {
2002        let s = ResolvedScoring::default();
2003        assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
2004        // Negative ages (clock skew, future timestamps) are also treated as fresh.
2005        assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
2006    }
2007
2008    #[test]
2009    fn scoring_legacy_disables_decay() {
2010        let cfg = RecallScoringConfig {
2011            legacy_scoring: true,
2012            ..Default::default()
2013        };
2014        let s = ResolvedScoring::from_config(Some(&cfg));
2015        // No decay regardless of age.
2016        assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
2017        assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
2018        assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
2019    }
2020
2021    #[test]
2022    fn effective_scoring_on_empty_config() {
2023        let cfg = AppConfig::default();
2024        let s = cfg.effective_scoring();
2025        assert_eq!(s.half_life_days_short, 7.0);
2026        assert!(!s.legacy_scoring);
2027    }
2028
2029    #[test]
2030    fn scoring_roundtrip_through_toml() {
2031        let toml_src = r"
2032[scoring]
2033half_life_days_short = 5.0
2034half_life_days_mid = 25.0
2035legacy_scoring = false
2036";
2037        let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
2038        let s = cfg.effective_scoring();
2039        assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
2040        assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
2041        // Unset long defaults.
2042        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
2043    }
2044
2045    // ---- Wave 3 (Closer T) tests for uncovered effective_* helpers
2046    // and write_default_if_missing. ----
2047
2048    #[test]
2049    fn effective_tier_cli_overrides_config() {
2050        let cfg = AppConfig {
2051            tier: Some("smart".to_string()),
2052            ..AppConfig::default()
2053        };
2054        // CLI flag wins over config.
2055        assert_eq!(
2056            cfg.effective_tier(Some("autonomous")),
2057            FeatureTier::Autonomous
2058        );
2059        // No CLI flag → config used.
2060        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
2061    }
2062
2063    #[test]
2064    fn effective_tier_unknown_falls_back_to_semantic() {
2065        let cfg = AppConfig::default();
2066        assert_eq!(
2067            cfg.effective_tier(Some("invalid-tier")),
2068            FeatureTier::Semantic
2069        );
2070        // No CLI, no config → default semantic.
2071        assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
2072    }
2073
2074    // ---- v0.6.4-001 — `effective_profile` resolution tests.
2075    //
2076    // Resolution order: CLI/env > [mcp].profile config > "core" default.
2077    // Clap merges CLI and env into the same `Option<&str>` before this
2078    // function sees it, so the function only needs to test "explicit
2079    // override > config > default". Env-var precedence over CLI cannot
2080    // happen by design (clap precedence is CLI > env), so it is not
2081    // tested at this layer.
2082
2083    #[test]
2084    fn effective_profile_cli_or_env_overrides_config() {
2085        let cfg = AppConfig {
2086            mcp: Some(McpConfig {
2087                profile: Some("graph".to_string()),
2088                allowlist: None,
2089            }),
2090            ..AppConfig::default()
2091        };
2092        // CLI/env value beats the config value.
2093        assert_eq!(
2094            cfg.effective_profile(Some("admin")).unwrap(),
2095            crate::profile::Profile::admin()
2096        );
2097        // No CLI/env → config used.
2098        assert_eq!(
2099            cfg.effective_profile(None).unwrap(),
2100            crate::profile::Profile::graph()
2101        );
2102    }
2103
2104    #[test]
2105    fn effective_profile_falls_back_to_core_default() {
2106        let cfg = AppConfig::default();
2107        // No mcp config, no CLI → core (the v0.6.4 default flip).
2108        assert_eq!(
2109            cfg.effective_profile(None).unwrap(),
2110            crate::profile::Profile::core()
2111        );
2112    }
2113
2114    #[test]
2115    fn effective_profile_surfaces_parse_error_for_unknown_family() {
2116        let cfg = AppConfig::default();
2117        assert!(matches!(
2118            cfg.effective_profile(Some("xyz")),
2119            Err(crate::profile::ProfileParseError::UnknownFamily(_))
2120        ));
2121    }
2122
2123    #[test]
2124    fn effective_profile_surfaces_parse_error_for_mixed_case() {
2125        let cfg = AppConfig::default();
2126        assert!(matches!(
2127            cfg.effective_profile(Some("Core")),
2128            Err(crate::profile::ProfileParseError::CaseMismatch(_))
2129        ));
2130    }
2131
2132    // ---- v0.6.4-008 — `[mcp.allowlist]` resolution tests.
2133
2134    fn allowlist_table(rows: &[(&str, &[&str])]) -> McpConfig {
2135        let mut map = std::collections::HashMap::new();
2136        for (k, v) in rows {
2137            map.insert(
2138                (*k).to_string(),
2139                v.iter().map(|s| (*s).to_string()).collect(),
2140            );
2141        }
2142        McpConfig {
2143            profile: None,
2144            allowlist: Some(map),
2145        }
2146    }
2147
2148    #[test]
2149    fn allowlist_disabled_when_table_absent() {
2150        let cfg = McpConfig::default();
2151        assert_eq!(
2152            cfg.allowlist_decision(Some("alice"), "graph"),
2153            AllowlistDecision::Disabled
2154        );
2155    }
2156
2157    #[test]
2158    fn allowlist_disabled_when_table_empty() {
2159        let cfg = McpConfig {
2160            profile: None,
2161            allowlist: Some(std::collections::HashMap::new()),
2162        };
2163        assert_eq!(
2164            cfg.allowlist_decision(Some("alice"), "graph"),
2165            AllowlistDecision::Disabled
2166        );
2167    }
2168
2169    #[test]
2170    fn allowlist_exact_match_grants_or_denies_per_family_set() {
2171        let cfg = allowlist_table(&[("alice", &["core", "graph"]), ("*", &["core"])]);
2172        assert_eq!(
2173            cfg.allowlist_decision(Some("alice"), "graph"),
2174            AllowlistDecision::Allow
2175        );
2176        assert_eq!(
2177            cfg.allowlist_decision(Some("alice"), "power"),
2178            AllowlistDecision::Deny
2179        );
2180    }
2181
2182    #[test]
2183    fn allowlist_full_grants_every_family() {
2184        let cfg = allowlist_table(&[("bob", &["full"])]);
2185        assert_eq!(
2186            cfg.allowlist_decision(Some("bob"), "graph"),
2187            AllowlistDecision::Allow
2188        );
2189        assert_eq!(
2190            cfg.allowlist_decision(Some("bob"), "archive"),
2191            AllowlistDecision::Allow
2192        );
2193    }
2194
2195    #[test]
2196    fn allowlist_wildcard_default_for_unknown_agents() {
2197        let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
2198        assert_eq!(
2199            cfg.allowlist_decision(Some("eve"), "core"),
2200            AllowlistDecision::Allow
2201        );
2202        assert_eq!(
2203            cfg.allowlist_decision(Some("eve"), "graph"),
2204            AllowlistDecision::Deny
2205        );
2206    }
2207
2208    #[test]
2209    fn allowlist_default_deny_when_no_wildcard() {
2210        let cfg = allowlist_table(&[("alice", &["full"])]);
2211        assert_eq!(
2212            cfg.allowlist_decision(Some("eve"), "core"),
2213            AllowlistDecision::Deny
2214        );
2215    }
2216
2217    #[test]
2218    fn allowlist_longest_prefix_match_wins() {
2219        let cfg = allowlist_table(&[
2220            ("ai:", &["core"]),
2221            ("ai:claude-code", &["full"]),
2222            ("*", &["core"]),
2223        ]);
2224        // The longer prefix takes precedence over the shorter one.
2225        assert_eq!(
2226            cfg.allowlist_decision(Some("ai:claude-code@host"), "graph"),
2227            AllowlistDecision::Allow
2228        );
2229        // Shorter prefix still works for other ai:* agents.
2230        assert_eq!(
2231            cfg.allowlist_decision(Some("ai:codex@host"), "graph"),
2232            AllowlistDecision::Deny
2233        );
2234    }
2235
2236    #[test]
2237    fn allowlist_no_agent_id_uses_wildcard() {
2238        // Tier-1 / anonymous: no agent_id provided → only the wildcard
2239        // rule is consulted.
2240        let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
2241        assert_eq!(
2242            cfg.allowlist_decision(None, "core"),
2243            AllowlistDecision::Allow
2244        );
2245        assert_eq!(
2246            cfg.allowlist_decision(None, "graph"),
2247            AllowlistDecision::Deny
2248        );
2249    }
2250
2251    #[test]
2252    fn effective_db_cli_path_wins_when_non_default() {
2253        let cfg = AppConfig {
2254            db: Some("/from/config.db".to_string()),
2255            ..AppConfig::default()
2256        };
2257        let cli_path = Path::new("/from/cli.db");
2258        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
2259    }
2260
2261    #[test]
2262    fn effective_db_falls_back_to_config_when_cli_default() {
2263        let cfg = AppConfig {
2264            db: Some("/from/config.db".to_string()),
2265            ..AppConfig::default()
2266        };
2267        // The CLI default is "ai-memory.db" — config wins for that case.
2268        assert_eq!(
2269            cfg.effective_db(Path::new("ai-memory.db")),
2270            PathBuf::from("/from/config.db")
2271        );
2272    }
2273
2274    #[test]
2275    fn effective_db_falls_back_to_cli_when_no_config() {
2276        let cfg = AppConfig::default();
2277        let cli_path = Path::new("ai-memory.db");
2278        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
2279    }
2280
2281    #[test]
2282    fn effective_ollama_url_default_when_unset() {
2283        let cfg = AppConfig::default();
2284        assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
2285    }
2286
2287    #[test]
2288    fn effective_ollama_url_uses_configured_value() {
2289        let cfg = AppConfig {
2290            ollama_url: Some("http://my-host:9999".to_string()),
2291            ..AppConfig::default()
2292        };
2293        assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
2294    }
2295
2296    #[test]
2297    fn effective_embed_url_falls_back_to_ollama_url() {
2298        let cfg = AppConfig {
2299            ollama_url: Some("http://ollama:11434".to_string()),
2300            ..AppConfig::default()
2301        };
2302        // No embed_url → fall back to ollama_url.
2303        assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
2304    }
2305
2306    #[test]
2307    fn effective_embed_url_uses_dedicated_value_when_set() {
2308        let cfg = AppConfig {
2309            ollama_url: Some("http://ollama:11434".to_string()),
2310            embed_url: Some("http://embed:8080".to_string()),
2311            ..AppConfig::default()
2312        };
2313        // Dedicated embed_url wins.
2314        assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
2315    }
2316
2317    #[test]
2318    fn effective_embed_url_uses_default_when_neither_set() {
2319        let cfg = AppConfig::default();
2320        assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
2321    }
2322
2323    #[test]
2324    fn effective_archive_on_gc_default_is_true() {
2325        let cfg = AppConfig::default();
2326        assert!(cfg.effective_archive_on_gc());
2327    }
2328
2329    #[test]
2330    fn effective_archive_on_gc_respects_explicit_false() {
2331        let cfg = AppConfig {
2332            archive_on_gc: Some(false),
2333            ..AppConfig::default()
2334        };
2335        assert!(!cfg.effective_archive_on_gc());
2336    }
2337
2338    #[test]
2339    fn effective_autonomous_hooks_default_is_false() {
2340        // SAFETY: clear env so this test is deterministic; tests run with
2341        // --test-threads=1 in CI for env-based tests, but we stay
2342        // defensive and set+unset locally.
2343        // SAFETY: env mutation is acceptable here because we set then unset.
2344        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
2345        let cfg = AppConfig::default();
2346        assert!(!cfg.effective_autonomous_hooks());
2347    }
2348
2349    #[test]
2350    fn effective_autonomous_hooks_config_value_used_when_env_unset() {
2351        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
2352        let cfg = AppConfig {
2353            autonomous_hooks: Some(true),
2354            ..AppConfig::default()
2355        };
2356        assert!(cfg.effective_autonomous_hooks());
2357    }
2358
2359    #[test]
2360    fn effective_anonymize_default_falls_back_to_config() {
2361        unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
2362        let cfg = AppConfig::default();
2363        assert!(!cfg.effective_anonymize_default());
2364    }
2365
2366    #[test]
2367    fn write_default_if_missing_creates_file_then_noops() {
2368        // Use a temp dir as $HOME so we don't clobber a real config.
2369        let tmp = tempfile::tempdir().unwrap();
2370        // SAFETY: env mutation is contained; we restore at end.
2371        unsafe { std::env::set_var("HOME", tmp.path()) };
2372        // First call writes the file.
2373        AppConfig::write_default_if_missing();
2374        let expected = AppConfig::config_path().unwrap();
2375        assert!(expected.exists(), "config not written at {expected:?}");
2376        let original = std::fs::read_to_string(&expected).unwrap();
2377        assert!(original.contains("ai-memory configuration"));
2378        // Second call must NOT overwrite (idempotent).
2379        std::fs::write(&expected, "# user-edited\n").unwrap();
2380        AppConfig::write_default_if_missing();
2381        let after = std::fs::read_to_string(&expected).unwrap();
2382        assert_eq!(after, "# user-edited\n");
2383    }
2384
2385    #[test]
2386    fn config_path_returns_some_when_home_set() {
2387        // SAFETY: env mutation contained to this test.
2388        unsafe { std::env::set_var("HOME", "/some/home") };
2389        let path = AppConfig::config_path().unwrap();
2390        assert!(path.starts_with("/some/home"));
2391    }
2392
2393    #[test]
2394    fn load_from_returns_default_for_missing_file() {
2395        // Non-existent path → default config.
2396        let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
2397        assert!(cfg.tier.is_none());
2398        assert!(cfg.db.is_none());
2399    }
2400
2401    #[test]
2402    fn load_from_returns_default_for_unparseable_toml() {
2403        // Garbage TOML → load_from prints a warning and returns default.
2404        let tmp = tempfile::NamedTempFile::new().unwrap();
2405        std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
2406        let cfg = AppConfig::load_from(tmp.path());
2407        assert!(cfg.tier.is_none());
2408    }
2409
2410    #[test]
2411    fn load_from_parses_valid_toml() {
2412        let tmp = tempfile::NamedTempFile::new().unwrap();
2413        std::fs::write(
2414            tmp.path(),
2415            r#"
2416                tier = "smart"
2417                db = "/disk.db"
2418            "#,
2419        )
2420        .unwrap();
2421        let cfg = AppConfig::load_from(tmp.path());
2422        assert_eq!(cfg.tier.as_deref(), Some("smart"));
2423        assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
2424    }
2425}