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 std::str::FromStr for EmbeddingModel {
24    type Err = String;
25
26    /// Parse the snake_case wire form used by `AppConfig.embedding_model`
27    /// (the documented top-level override). Accepts case-insensitive input
28    /// with surrounding whitespace trimmed. Keep this in sync with the
29    /// `#[serde(rename_all = "snake_case")]` variants above.
30    fn from_str(s: &str) -> Result<Self, Self::Err> {
31        match s.trim().to_ascii_lowercase().as_str() {
32            "mini_lm_l6_v2" => Ok(Self::MiniLmL6V2),
33            "nomic_embed_v15" => Ok(Self::NomicEmbedV15),
34            other => Err(format!(
35                "unknown embedding_model {other:?}: expected one of \
36                 \"mini_lm_l6_v2\", \"nomic_embed_v15\""
37            )),
38        }
39    }
40}
41
42impl EmbeddingModel {
43    /// Embedding vector dimensionality.
44    pub fn dim(self) -> usize {
45        match self {
46            Self::MiniLmL6V2 => 384,
47            Self::NomicEmbedV15 => 768,
48        }
49    }
50
51    /// `HuggingFace` model identifier.
52    pub fn hf_model_id(&self) -> &str {
53        match self {
54            Self::MiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
55            Self::NomicEmbedV15 => "nomic-ai/nomic-embed-text-v1.5",
56        }
57    }
58
59    /// Canonical-id aliases recognised by [`Self::from_canonical_id`]:
60    /// the snake wire form ([`FromStr`]), the HF id (also the
61    /// [`canonicalise_embedding_model`] output), the unprefixed
62    /// shortname, and the Ollama tag. Centralising the alias strings
63    /// here keeps the model-id literals in one place (#1521).
64    fn canonical_aliases(self) -> &'static [&'static str] {
65        match self {
66            Self::MiniLmL6V2 => MINILM_CANONICAL_ALIASES,
67            Self::NomicEmbedV15 => NOMIC_CANONICAL_ALIASES,
68        }
69    }
70
71    /// Parse any recognised canonical id form — the snake wire form, the
72    /// HF id, the unprefixed shortname, or the Ollama tag — into a
73    /// daemon-constructible model. Returns `None` for ids the 2-model
74    /// daemon embedder cannot build (e.g. `bge-large-en`); callers fall
75    /// back to the tier preset. Case-insensitive; surrounding whitespace
76    /// is trimmed (#1521).
77    ///
78    /// Unlike [`FromStr`] (which only accepts the snake wire form), this
79    /// also accepts whatever an operator wrote in `[embeddings].model`
80    /// after [`canonicalise_embedding_model`], so the sectioned config
81    /// block drives the daemon embedder.
82    #[must_use]
83    pub fn from_canonical_id(s: &str) -> Option<Self> {
84        let needle = s.trim();
85        if needle.is_empty() {
86            return None;
87        }
88        [Self::MiniLmL6V2, Self::NomicEmbedV15]
89            .into_iter()
90            .find(|model| {
91                model
92                    .canonical_aliases()
93                    .iter()
94                    .any(|alias| alias.eq_ignore_ascii_case(needle))
95            })
96    }
97}
98
99/// Canonical-id aliases for [`EmbeddingModel::MiniLmL6V2`] — snake wire
100/// form, HF id ([`canonicalise_embedding_model`] output), unprefixed
101/// shortname, Ollama tag. See [`EmbeddingModel::from_canonical_id`].
102const MINILM_CANONICAL_ALIASES: &[&str] = &[
103    "mini_lm_l6_v2",
104    "sentence-transformers/all-MiniLM-L6-v2",
105    "all-MiniLM-L6-v2",
106    "all-minilm",
107];
108
109/// Canonical-id aliases for [`EmbeddingModel::NomicEmbedV15`] — snake
110/// wire form, HF id ([`canonicalise_embedding_model`] output), Ollama
111/// tag, prefixed HF id. See [`EmbeddingModel::from_canonical_id`].
112const NOMIC_CANONICAL_ALIASES: &[&str] = &[
113    "nomic_embed_v15",
114    "nomic-embed-text-v1.5",
115    "nomic-embed-text",
116    "nomic-ai/nomic-embed-text-v1.5",
117];
118
119// ---------------------------------------------------------------------------
120// Config key names
121// ---------------------------------------------------------------------------
122
123/// Canonical name strings for the legacy v1 flat config keys (plus the
124/// `[embeddings]` section name) that appear on multiple production
125/// sites (#1558). Shared between the `AppConfig` surface in this file
126/// (the manual `Debug` impl + `warn_unknown_top_level_keys`) and the
127/// `ai-memory config migrate` rewriter in
128/// `src/cli/commands/config.rs`, so each key spelling has one source
129/// of truth. The serde wire names themselves derive from the
130/// `AppConfig` field identifiers (no `#[serde(rename)]`), so serde
131/// needs no literal at all.
132pub mod config_keys {
133    /// Legacy flat `archive_max_days` key (v2: `[storage].archive_max_days`).
134    pub const ARCHIVE_MAX_DAYS: &str = "archive_max_days";
135    /// Legacy flat `archive_on_gc` key (v2: `[storage].archive_on_gc`).
136    pub const ARCHIVE_ON_GC: &str = "archive_on_gc";
137    /// Legacy flat `auto_tag_model` key (v2: `[llm.auto_tag].model`).
138    pub const AUTO_TAG_MODEL: &str = "auto_tag_model";
139    /// Legacy flat `cross_encoder` key (v2: `[reranker].enabled`).
140    pub const CROSS_ENCODER: &str = "cross_encoder";
141    /// Legacy flat `default_namespace` key (v2: `[storage].default_namespace`).
142    pub const DEFAULT_NAMESPACE: &str = "default_namespace";
143    /// Legacy flat `embedding_model` key (v2: `[embeddings].model`).
144    pub const EMBEDDING_MODEL: &str = "embedding_model";
145    /// Legacy flat `max_memory_mb` key (v2: resolved via `[storage]`).
146    pub const MAX_MEMORY_MB: &str = "max_memory_mb";
147    /// Legacy flat `ollama_url` key (v2: `[llm].base_url` / `[embeddings].url`).
148    pub const OLLAMA_URL: &str = "ollama_url";
149    /// `[embeddings]` config-section name (#1146 sectioned schema).
150    pub const SECTION_EMBEDDINGS: &str = "embeddings";
151}
152
153// ---------------------------------------------------------------------------
154// LLM model defaults
155// ---------------------------------------------------------------------------
156
157/// Provider-agnostic default backend LLM model tag for the LLM-capable
158/// feature tiers (smart / autonomous).
159///
160/// The NAME is vendor-agnostic by design (#1067 / #1146 / #1490): ai-memory
161/// speaks to ANY backend — local Ollama, OpenAI, Anthropic, xAI, Gemini,
162/// Groq, OpenRouter, or any OpenAI-compatible endpoint — selected via the
163/// `[llm]` config section or `AI_MEMORY_LLM_*` env vars. The VALUE returned
164/// here is only the compiled fallback used when no model is configured at any
165/// precedence layer; it is identical to [`backend_default_model`]'s catch-all
166/// arm (the single source of truth for the local-Ollama default tag) and is
167/// overridden at every layer in the resolver ladder
168/// (CLI > env > `[llm]` > legacy flat field > this compiled default).
169///
170/// No vendor/model name is baked into any tier-config identifier — the tier
171/// presets carry this resolved default string, not a model-named enum.
172#[must_use]
173pub fn default_tier_llm_model() -> &'static str {
174    backend_default_model(crate::llm::BACKEND_OLLAMA)
175}
176
177// ---------------------------------------------------------------------------
178// Feature tiers
179// ---------------------------------------------------------------------------
180
181/// Feature tiers control which AI capabilities are active based on the
182/// available memory budget on the host machine.
183///
184/// # Disambiguation (issue #970)
185///
186/// The codebase has three enums whose names end in `Tier`.
187/// `FeatureTier` (this enum) is the **host capability tier** that
188/// gates which AI features fit in RAM (0 / 256 MB / 1 GB / 4 GB). It
189/// is unrelated to:
190///
191/// - [`crate::models::Tier`] — memory-lifecycle TTL bucket
192///   (Short/Mid/Long).
193/// - [`crate::models::ConfidenceTier`] — confidence-value bucket
194///   (Confirmed/Likely/Ambiguous).
195///
196/// They do not share variants, wire strings, or call sites. See
197/// `docs/internal/enum-proliferation-audit-970.md`.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum FeatureTier {
201    /// FTS5 keyword search only — 0 MB extra.
202    Keyword,
203    /// `MiniLM` embeddings + HNSW index — ~256 MB.
204    Semantic,
205    /// nomic-embed + a backend LLM (any configured provider) — ~1 GB.
206    Smart,
207    /// nomic-embed + a backend LLM (any configured provider) + cross-encoder — ~4 GB.
208    Autonomous,
209}
210
211impl FeatureTier {
212    /// Parse a tier name (case-insensitive).
213    pub fn from_str(s: &str) -> Option<Self> {
214        match s.to_ascii_lowercase().as_str() {
215            "keyword" => Some(Self::Keyword),
216            "semantic" => Some(Self::Semantic),
217            "smart" => Some(Self::Smart),
218            "autonomous" => Some(Self::Autonomous),
219            _ => None,
220        }
221    }
222
223    /// Canonical lowercase name.
224    pub fn as_str(&self) -> &str {
225        match self {
226            Self::Keyword => "keyword",
227            Self::Semantic => "semantic",
228            Self::Smart => "smart",
229            Self::Autonomous => "autonomous",
230        }
231    }
232
233    /// Build the full [`TierConfig`] for this tier.
234    pub fn config(self) -> TierConfig {
235        match self {
236            Self::Keyword => TierConfig {
237                tier: self,
238                embedding_model: None,
239                llm_model: None,
240                cross_encoder: false,
241                max_memory_mb: 0,
242            },
243            Self::Semantic => TierConfig {
244                tier: self,
245                embedding_model: Some(EmbeddingModel::MiniLmL6V2),
246                llm_model: None,
247                cross_encoder: false,
248                max_memory_mb: 256,
249            },
250            Self::Smart => TierConfig {
251                tier: self,
252                embedding_model: Some(EmbeddingModel::NomicEmbedV15),
253                llm_model: Some(default_tier_llm_model().to_string()),
254                cross_encoder: false,
255                max_memory_mb: 1024,
256            },
257            Self::Autonomous => TierConfig {
258                tier: self,
259                embedding_model: Some(EmbeddingModel::NomicEmbedV15),
260                llm_model: Some(default_tier_llm_model().to_string()),
261                cross_encoder: true,
262                max_memory_mb: 4096,
263            },
264        }
265    }
266
267    /// Automatically select the best tier that fits within `mb` megabytes.
268    #[allow(dead_code)]
269    pub fn from_memory_budget(mb: usize) -> Self {
270        if mb >= 4096 {
271            Self::Autonomous
272        } else if mb >= 1024 {
273            Self::Smart
274        } else if mb >= 256 {
275            Self::Semantic
276        } else {
277            Self::Keyword
278        }
279    }
280}
281
282impl std::fmt::Display for FeatureTier {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        f.write_str(self.as_str())
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Tier configuration
290// ---------------------------------------------------------------------------
291
292/// Runtime configuration derived from a [`FeatureTier`].
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct TierConfig {
295    pub tier: FeatureTier,
296    pub embedding_model: Option<EmbeddingModel>,
297    /// Default backend LLM model tag for this tier, or `None` for tiers that
298    /// use no LLM (keyword / semantic). The value is the provider-agnostic
299    /// compiled default ([`default_tier_llm_model`]); the operator-resolved
300    /// backend/model is carried by [`ResolvedLlm`] via [`AppConfig::resolve_llm`]
301    /// and can be ANY backend. Treated as an on/off gate at the call sites.
302    pub llm_model: Option<String>,
303    pub cross_encoder: bool,
304    pub max_memory_mb: usize,
305}
306
307impl TierConfig {
308    /// Produce a [`Capabilities`] (schema v2) report suitable for JSON
309    /// serialisation. The MCP / HTTP `handle_capabilities_with_conn`
310    /// wrapper overlays live runtime state (recall mode, reranker mode,
311    /// embedder-loaded flag) and live DB counts (active rules, hook
312    /// registrations, pending approvals) before the report goes on the
313    /// wire.
314    ///
315    /// v2 honesty patch (P1, v0.6.3.1): `recall_mode_active` and
316    /// `reranker_active` start at conservative defaults (`disabled` /
317    /// `off`); the wrapper updates them based on the *runtime* embedder
318    /// + reranker handles, not the *configured* tier values.
319    ///
320    /// **#1168 back-compat shim.** Delegates to
321    /// [`Self::capabilities_with_resolved`] with a
322    /// [`ResolvedModels::from_tier_preset`] triple so the
323    /// pre-#1168 wire shape is byte-equal for callers (legacy tests,
324    /// migrate-tool diagnostics) that don't load an operator
325    /// [`AppConfig`]. Production wrappers MUST call
326    /// [`Self::capabilities_with_resolved`] directly with
327    /// [`AppConfig::resolve_models`] output — otherwise
328    /// `memory_capabilities.models.*` drifts from the live LLM /
329    /// embedder / reranker wiring.
330    pub fn capabilities(&self) -> Capabilities {
331        self.capabilities_with_resolved(&ResolvedModels::from_tier_preset(self))
332    }
333
334    /// v0.7.x (issue #1168) — resolver-aware capabilities builder.
335    ///
336    /// Identical to [`Self::capabilities`] except `models.embedding` /
337    /// `models.llm` / `models.cross_encoder` come from the
338    /// operator-resolved `models` triple (built via
339    /// [`AppConfig::resolve_models`]) instead of the compiled tier
340    /// preset. This is the production entry point used by every
341    /// `handle_capabilities_with_conn[_v3]` wrapper post-#1168.
342    ///
343    /// The display logic mirrors the boot banner
344    /// (`src/cli/boot.rs` `BootManifest::build`): Ollama-backend LLM
345    /// emits the bare model id (legacy banner shape); other backends
346    /// emit `backend:model`. Embedder + reranker respect the
347    /// tier-preset disable flag so the keyword tier still reports
348    /// `embedding="none"` even if an operator left a stale
349    /// `[embeddings]` block in their config.
350    #[must_use]
351    pub fn capabilities_with_resolved(&self, models: &ResolvedModels) -> Capabilities {
352        let has_embeddings = self.embedding_model.is_some();
353        let has_llm = self.llm_model.is_some();
354
355        Capabilities {
356            // Capabilities schema v2 — see `Capabilities` doc comment.
357            schema_version: "2".to_string(),
358            tier: self.tier.as_str().to_string(),
359            version: crate::PKG_VERSION.to_string(),
360            features: CapabilityFeatures {
361                keyword_search: true,
362                semantic_search: has_embeddings,
363                hybrid_recall: has_embeddings,
364                query_expansion: has_llm,
365                auto_consolidation: has_llm,
366                auto_tagging: has_llm,
367                contradiction_analysis: has_llm,
368                cross_encoder_reranking: self.cross_encoder,
369                // v0.7.0 recursive-learning (issue #655): the primitive
370                // shipped — Tasks 1-6 landed on
371                // `feat/v0.7.0-recursive-learning`. Flag is enabled and
372                // pinned to the shipping version `v0.7.0`. (Pre-ship,
373                // this was `PlannedFeature::planned("v0.7+")` to keep
374                // the v2 honesty contract honest while the substrate
375                // primitive was on the roadmap.)
376                memory_reflection: PlannedFeature {
377                    planned: false,
378                    version: "v0.7.0".to_string(),
379                    enabled: true,
380                },
381                // Default false — the HTTP/MCP capabilities handler
382                // overwrites this with the live runtime state when it
383                // has access to the embedder handle.
384                embedder_loaded: false,
385                // Conservative defaults; the handler wrapper overlays the
386                // live runtime state (`hybrid` when embedder is loaded,
387                // `keyword_only` when it is not, `degraded` if the load
388                // failed, `disabled` for the keyword tier).
389                recall_mode_active: RecallMode::Disabled,
390                // Conservative default; overwritten when the wrapper has
391                // the actual reranker handle. `off` means no reranker is
392                // configured; `lexical_fallback` means the neural model
393                // failed to materialize; `neural` means the BERT
394                // cross-encoder is loaded.
395                reranker_active: RerankerMode::Off,
396                // v0.7.0 L2-8 — default reflection boost (1.2, +0.05/depth,
397                // cap=3). The MCP/HTTP wrapper overlays the live wrapper
398                // config when a `BatchedReranker` handle is available.
399                reflection_boost: ReflectionBoostReport::default(),
400            },
401            models: build_capability_models(self, models),
402            // v2 dynamic blocks — start at zero-state defaults. The MCP
403            // and HTTP `handle_capabilities` wrappers overwrite these
404            // with live counts when they have a `&Connection` handle.
405            //
406            // Honesty patch (P1): `permissions.mode` is `"advisory"`
407            // until P4 lands the enforcement gate. Was `"ask"`, which
408            // implied an active prompt loop that does not exist.
409            // `rule_summary`, `hooks.by_event`, `approval.subscribers`,
410            // and `approval.default_timeout_seconds` were dropped in v2
411            // because they have no backing implementation.
412            permissions: CapabilityPermissions {
413                // v0.7.0 K3: surface the *active* mode (the one the
414                // gate will actually consult), not a hard-coded string.
415                // Falls through to the K3 default (`advisory`) when
416                // `[permissions].mode` is unset in `config.toml`.
417                mode: active_permissions_mode().as_str().to_string(),
418                active_rules: 0,
419                // v0.7.0 K5: zero-state — no policies known until the
420                // overlay queries the live DB. `Vec::is_empty` means
421                // the field is omitted from the wire entirely (matches
422                // the v0.6.3.1 honesty disclosure that this field was
423                // previously dropped because no per-rule serializer
424                // existed; K5 ships the serializer).
425                rule_summary: Vec::new(),
426                // v0.6.3.1 (P4, G1): chain-walking enforcement landed
427                // in this release. Surface "enforced" so consumers can
428                // distinguish a governed deployment from the historical
429                // "display_only" posture.
430                inheritance: Some("enforced".to_string()),
431                // v0.7.0 K3: per-mode decision counts. Snapshot at
432                // capability-build time so operators can correlate
433                // doctor reports with capability responses.
434                decision_counts: Some(permissions_decision_counts()),
435            },
436            hooks: CapabilityHooks::default(),
437            compaction: CapabilityCompaction::planned(),
438            approval: CapabilityApproval {
439                pending_requests: 0,
440                deferred_audit_dlq_size: 0,
441            },
442            // v0.7.0 #1324 — substrate ships at v0.7.0; flag reads
443            // `planned: false, enabled: false` until an operator wires
444            // the R5 extraction hook and rows land in `memory_transcripts`.
445            // The MCP / HTTP overlay flips `enabled: true` when the live
446            // count is non-zero.
447            transcripts: CapabilityTranscripts::shipped(),
448            hnsw: CapabilityHnsw::default(),
449            // v0.7 J1 — populated by the SAL wrapper at runtime when a
450            // Postgres adapter is active. None at config-construction
451            // time (no SAL handle here); the MCP/HTTP wrapper overlays
452            // the live tag from `PostgresStore::kg_backend()` once
453            // J2 wires the SAL into AppState.
454            kg_backend: None,
455            // L1-1 — always static for v0.7.0; Goal/Plan/Step/Decision
456            // land in L1-6/v0.8.0.
457            memory_kinds: default_memory_kinds(),
458        }
459    }
460}
461
462// ---------------------------------------------------------------------------
463// Capability reporting
464// ---------------------------------------------------------------------------
465
466/// Top-level capabilities report for a running instance.
467///
468/// Schema versions:
469/// - **v1** (legacy, pre-v0.6.3.1): `tier`, `version`, `features`,
470///   `models`. Reachable via `Accept-Capabilities: v1` (HTTP) or the MCP
471///   `accept` argument set to `"v1"`. See [`CapabilitiesV1`].
472/// - **v2** (v0.6.3.1 honesty patch): `schema_version="2"` plus the
473///   `permissions`, `hooks`, `compaction`, `approval`, `transcripts`
474///   blocks. v1 fields preserved at the same top-level paths — old
475///   clients that read v2 by name continue to work for the un-dropped
476///   fields. Default response shape.
477///
478/// **v2 honesty patch (P1, v0.6.3.1):**
479/// - `features.recall_mode_active` and `features.reranker_active` are
480///   *runtime* state, not config-derived flags.
481/// - `features.memory_reflection` is now a `{planned, version, enabled}`
482///   object, not a `bool`.
483/// - `compaction` and `transcripts` carry the same planned-feature
484///   shape so operators can distinguish "disabled but built" from "not
485///   in this build."
486/// - `permissions.mode = "advisory"` until the enforcement gate ships
487///   in P4. Was `"ask"`, which implied an active interactive loop.
488/// - The following fields were **removed** because no backing
489///   implementation exists: `permissions.rule_summary`,
490///   `hooks.by_event`, `approval.subscribers`,
491///   `approval.default_timeout_seconds`.
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct Capabilities {
494    /// Schema-version discriminator. Always `"2"` since v0.6.3.
495    pub schema_version: String,
496    pub tier: String,
497    pub version: String,
498    pub features: CapabilityFeatures,
499    pub models: CapabilityModels,
500
501    /// Active permission/governance rules. Pre-P4 reports the count of
502    /// namespaces that have a `metadata.governance` policy attached to
503    /// their standard memory; the underlying permission system itself
504    /// is P4 work.
505    pub permissions: CapabilityPermissions,
506
507    /// Registered hooks. Pre-v0.7 reports webhook subscriptions as a
508    /// proxy (hook system itself is v0.7 Bucket 0).
509    pub hooks: CapabilityHooks,
510
511    /// Compaction state. v0.8 work — reports `{planned, version,
512    /// enabled}` until the subsystem ships.
513    pub compaction: CapabilityCompaction,
514
515    /// Approval API state. Reports the live count of pending actions
516    /// from the existing `pending_actions` table.
517    pub approval: CapabilityApproval,
518
519    /// Sidechain-transcript state. v0.7 Bucket 1.7 work — reports
520    /// `{planned, version, enabled}` until the subsystem ships.
521    pub transcripts: CapabilityTranscripts,
522
523    /// v0.6.3.1 (P3, G2): HNSW vector-index health. Defaults to a
524    /// quiet zero-state report; the MCP/HTTP capabilities wrapper
525    /// overwrites with live process counters when the index module
526    /// has run an eviction.
527    #[serde(default)]
528    pub hnsw: CapabilityHnsw,
529
530    /// v0.7 J1 — knowledge-graph backend tag. `"age"` when a Postgres
531    /// SAL adapter probed Apache AGE successfully at connect time;
532    /// `"cte"` when the deployment falls back to the recursive-CTE
533    /// path (every SQLite deployment + Postgres without AGE
534    /// installed). `None` when no SAL adapter is wired (the active
535    /// dispatch path through the legacy `crate::db` free functions
536    /// pre-J2). Operators consult this through `ai-memory doctor` and
537    /// `memory_capabilities` to verify which traversal path their
538    /// daemon actually runs. Skipped from the JSON wire when `None`
539    /// so v1 / v2 clients that don't know the field round-trip cleanly.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub kg_backend: Option<String>,
542
543    /// L1-1 (v0.7.0) — the set of typed memory kinds this binary
544    /// supports.  Always `["observation", "reflection"]` for v0.7.0;
545    /// Goal/Plan/Step/Decision land in L1-6/v0.8.0.  Callers that want
546    /// to enumerate valid values for a `memory_kind` filter should
547    /// consult this field rather than hardcoding the list.
548    ///
549    /// `#[serde(default)]` keeps older capabilities consumers that
550    /// don't know the field from breaking.
551    #[serde(default = "default_memory_kinds")]
552    pub memory_kinds: Vec<String>,
553}
554
555/// v0.7.0 Gap 4 (#887) — the three thresholds powering the
556/// `ConfidenceTier` enum. `confirmed` and `likely` are inclusive
557/// lower bounds; `ambiguous` is the implicit floor (everything below
558/// `likely`).
559#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
560pub struct ConfidenceTierThresholds {
561    pub confirmed: f64,
562    pub likely: f64,
563    pub ambiguous: f64,
564}
565
566impl Default for ConfidenceTierThresholds {
567    fn default() -> Self {
568        // Mirrors the constants on `crate::models::ConfidenceTier`.
569        // Cannot reference them directly here without inducing a
570        // semantic cycle through `confidence::DEFAULT_HALF_LIFE_DAYS`
571        // already imported in this module; the
572        // `confidence_tier_thresholds_match_model_constants` test
573        // below pins the agreement at build time.
574        Self {
575            confirmed: 0.95,
576            likely: 0.7,
577            ambiguous: 0.0,
578        }
579    }
580}
581
582/// Live recall-mode tag (P1 honesty patch). Reflects the *runtime*
583/// state of the embedder + LLM, not the configured tier.
584///
585/// - `Hybrid` — embedder loaded; semantic + keyword blending active.
586/// - `KeywordOnly` — no embedder loaded; FTS5 only.
587/// - `Degraded` — embedder configured but `Embedder::load()` failed
588///   (offline runner, read-only fs, missing HF token, etc.).
589/// - `Disabled` — keyword-tier daemon, semantic recall not configured.
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
591#[serde(rename_all = "snake_case")]
592pub enum RecallMode {
593    Hybrid,
594    KeywordOnly,
595    Degraded,
596    Disabled,
597}
598
599/// Live reranker-mode tag (P1 honesty patch). Reflects the *runtime*
600/// `CrossEncoder` enum variant, not the configured `cross_encoder` flag.
601///
602/// - `Neural` — `CrossEncoder::Neural` loaded successfully.
603/// - `LexicalFallback` — `cross_encoder` was requested but neural model
604///   download or load failed; running on the lexical scorer.
605/// - `Off` — no reranker handle in the daemon (non-autonomous tier).
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
607#[serde(rename_all = "snake_case")]
608pub enum RerankerMode {
609    Neural,
610    LexicalFallback,
611    Off,
612}
613
614/// Generic "planned but not implemented" marker used by v2 capability
615/// fields whose underlying subsystem is on the roadmap but not in this
616/// build. Operators reading the JSON can distinguish "disabled but
617/// available" from "not in this build" by inspecting `planned`.
618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
619pub struct PlannedFeature {
620    /// `true` when the feature exists only on the roadmap.
621    pub planned: bool,
622    /// Earliest release that is expected to ship the feature, e.g.
623    /// `"v0.7+"` or `"v0.8+"`. Free-form string; clients should treat
624    /// it as advisory.
625    pub version: String,
626    /// `true` only when the feature is built **and** turned on in this
627    /// daemon. Always `false` when `planned == true`.
628    pub enabled: bool,
629}
630
631impl PlannedFeature {
632    /// A planned-not-yet-shipped feature. `enabled = false`.
633    #[must_use]
634    pub fn planned(version: &str) -> Self {
635        Self {
636            planned: true,
637            version: version.to_string(),
638            enabled: false,
639        }
640    }
641}
642
643/// Boolean feature flags exposed in the capabilities report.
644#[allow(clippy::struct_excessive_bools)]
645#[derive(Debug, Clone, Serialize, Deserialize)]
646pub struct CapabilityFeatures {
647    pub keyword_search: bool,
648    pub semantic_search: bool,
649    pub hybrid_recall: bool,
650    pub query_expansion: bool,
651    pub auto_consolidation: bool,
652    pub auto_tagging: bool,
653    pub contradiction_analysis: bool,
654    pub cross_encoder_reranking: bool,
655    /// Memory-reflection (v0.7.0): planned-feature object. Was a
656    /// `bool` before the v0.6.3.1 P1 honesty patch; an object now so
657    /// operators can tell "feature exists but disabled" apart from
658    /// "feature not in this build".
659    ///
660    /// **v0.7.0 recursive-learning ship (issue #655).** The flag is
661    /// `{ planned: false, version: "v0.7.0", enabled: true }` because
662    /// the underlying primitive landed across Tasks 1-6 on
663    /// `feat/v0.7.0-recursive-learning`:
664    ///
665    /// - **Column** (Task 1/8, commit `f5d8a9e`) —
666    ///   `memories.reflection_depth INTEGER NOT NULL DEFAULT 0`,
667    ///   first added in the recursive-learning schema bump (column
668    ///   inventory lives in `docs/MIGRATION_v0.7.md`; the current
669    ///   `CURRENT_SCHEMA_VERSION` is 53 in lockstep on both sqlite
670    ///   and postgres ladders as of v0.7.0 — v48 added
671    ///   `federation_push_dlq` (#933), v49 added 14 nullable
672    ///   `archived_memories` columns (#1025), v50 extended
673    ///   `agent_quotas` PK with `namespace` (#1156), v51 added
674    ///   `federation_nonce_cache` (#1255 / PR #1296), v52 added
675    ///   `transcript_line_dedup` (#1389 L4 / RFC-0001), v53 scoped
676    ///   the `memories_au` FTS5 sync trigger to (title, content, tags)
677    ///   only (R5.F5.2 / #1418)).
678    ///   `Memory::reflection_depth: i32` with `#[serde(default)]` for
679    ///   wire-compat with pre-v0.7.0 federation peers.
680    /// - **Governance field** (Task 2/8, commit `630a6db`) —
681    ///   `GovernancePolicy.max_reflection_depth: Option<u32>` (per
682    ///   namespace, JSON metadata, no schema bump). Accessor
683    ///   `effective_max_reflection_depth() -> u32` returns the compiled
684    ///   default `3` when unset; `Some(0)` is the documented
685    ///   kill-switch.
686    /// - **Relation** (Task 3/8, commit `b51a3f3`) — `reflects_on`
687    ///   joins the canonical `VALID_RELATIONS` set; directionality
688    ///   matches `derived_from` (reflection is `source_id`, original
689    ///   is `target_id`); `db::find_paths` walks it without further
690    ///   work.
691    /// - **MCP tool** (Task 4/8, commit `3dc76f3`) — `memory_reflect`
692    ///   (`Family::Power`, tool count 51 → 52). Atomic insert of a
693    ///   reflection memory + N `reflects_on` link writes inside a
694    ///   single `BEGIN IMMEDIATE` / `COMMIT` transaction. Postgres
695    ///   parity via inherent `PostgresStore::reflect`.
696    /// - **Error variant** (Task 4/8) — `MemoryError::ReflectionDepthExceeded
697    ///   { attempted: u32, cap: u32, namespace: String }` →
698    ///   HTTP `409 CONFLICT`, code `REFLECTION_DEPTH_EXCEEDED`.
699    /// - **Hook events** (Task 6/8, commit `fbf093c`) —
700    ///   `HookEvent::PreReflect` (decision-class, `EventClass::Write`,
701    ///   5s deadline, fires before the depth-cap check, `Deny`
702    ///   vetoes via `ReflectError::HookVeto`) +
703    ///   `HookEvent::PostReflect` (notify-class, `EventClass::Write`,
704    ///   5s deadline, fires after `COMMIT`). Pipeline event count
705    ///   21 → 23.
706    /// - **Audit chain** (Task 5/8, commit `c61a05b`) — every
707    ///   depth-cap refusal appends a `reflection.depth_exceeded` row
708    ///   to the append-only `signed_events` audit table under a
709    ///   canonical-CBOR payload + SHA-256 `payload_hash` +
710    ///   `attest_level = "unsigned"`. Content body is deliberately
711    ///   omitted (PII guarantee); hook vetoes are NOT audited by this
712    ///   row (caller-policy refusals carry their own provenance).
713    ///
714    /// The v1 wire-shape projection collapses this object back to a
715    /// single `bool` (via `Capabilities::to_v1`), so pre-v0.6.3.1
716    /// clients that pinned the v1 schema continue to see the same
717    /// boolean field at the same path (and now read `true`).
718    pub memory_reflection: PlannedFeature,
719    /// v0.6.2 (S18): runtime-observed embedder state. `semantic_search`
720    /// above reflects *configured* capability (derived from the tier's
721    /// `embedding_model` setting). `embedder_loaded` reflects *actual*
722    /// state after `Embedder::load()` attempted to materialize the
723    /// `HuggingFace` model on startup. When an operator configures the
724    /// `semantic` tier but the model download or mmap fails (offline
725    /// runner, read-only fs, missing tokens), `semantic_search=true`
726    /// would mislead. This flag exposes the truth so setup scripts can
727    /// assert the daemon is actually ready for semantic recall before
728    /// dispatching scenarios. Default false; populated by
729    /// `handle_capabilities` when the HTTP/MCP wrapper hands in the
730    /// live embedder handle.
731    #[serde(default)]
732    pub embedder_loaded: bool,
733    /// v0.6.3.1 (P1 honesty patch): runtime recall-mode tag. Reflects
734    /// the live embedder + LLM availability, not the configured tier.
735    /// See [`RecallMode`].
736    #[serde(default = "default_recall_mode")]
737    pub recall_mode_active: RecallMode,
738    /// v0.6.3.1 (P1 honesty patch): runtime reranker-mode tag.
739    /// Reflects the live `CrossEncoder` variant. See [`RerankerMode`].
740    #[serde(default = "default_reranker_mode")]
741    pub reranker_active: RerankerMode,
742    /// v0.7.0 L2-8 — reflection-aware reranker boost configuration.
743    /// `boost = 1.0` means the boost is disabled and the reranker
744    /// reproduces its pre-L2-8 behavior. Default (`1.2`) is the value
745    /// the daemon ships with; operators can inspect this to verify
746    /// the live boost matches their configured policy. Skipped from
747    /// the wire when serialising a pre-L2-8 default so older
748    /// capabilities consumers round-trip cleanly.
749    #[serde(default = "default_reflection_boost")]
750    pub reflection_boost: ReflectionBoostReport,
751}
752
753/// v0.7.0 L2-8 — per-field report of the reflection-aware reranker
754/// boost surfaced through `memory_capabilities`. Mirrors
755/// [`crate::reranker::ReflectionBoostConfig`] but expressed in
756/// capability-report shape (serde-friendly, schema-tagged).
757#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
758pub struct ReflectionBoostReport {
759    /// Multiplicative boost applied to reflection-kind memories.
760    /// `1.0` disables; default `1.2`.
761    pub boost: f32,
762    /// Per-depth additional multiplier increment. Default `0.05`.
763    pub per_depth_increment: f32,
764    /// Depth cap for the per-depth multiplier. Default `3`.
765    pub max_depth_cap: u32,
766}
767
768impl Default for ReflectionBoostReport {
769    fn default() -> Self {
770        Self {
771            boost: crate::reranker::DEFAULT_REFLECTION_BOOST,
772            per_depth_increment: crate::reranker::DEFAULT_REFLECTION_PER_DEPTH_INCREMENT,
773            max_depth_cap: crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP,
774        }
775    }
776}
777
778impl From<&crate::reranker::ReflectionBoostConfig> for ReflectionBoostReport {
779    fn from(cfg: &crate::reranker::ReflectionBoostConfig) -> Self {
780        Self {
781            boost: cfg.boost,
782            per_depth_increment: cfg.per_depth_increment,
783            max_depth_cap: cfg.max_depth_cap,
784        }
785    }
786}
787
788fn default_reflection_boost() -> ReflectionBoostReport {
789    ReflectionBoostReport::default()
790}
791
792/// L1-1 default: the two typed memory kinds shipping in v0.7.0.
793fn default_memory_kinds() -> Vec<String> {
794    vec!["observation".to_string(), "reflection".to_string()]
795}
796
797fn default_recall_mode() -> RecallMode {
798    RecallMode::Disabled
799}
800
801fn default_reranker_mode() -> RerankerMode {
802    RerankerMode::Off
803}
804
805/// Model identifiers exposed in the capabilities report.
806#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct CapabilityModels {
808    pub embedding: String,
809    pub embedding_dim: usize,
810    pub llm: String,
811    pub cross_encoder: String,
812}
813
814/// v0.7.x (issue #1168) — build the `models.*` block of the
815/// capabilities report from the resolver-aware
816/// [`ResolvedModels`] triple, NOT the compiled tier preset.
817///
818/// Display logic mirrors `src/cli/boot.rs` `BootManifest::build`
819/// (v0.7.x #1146) so the boot banner and `memory_capabilities`
820/// agree byte-for-byte on what backend / model the daemon is
821/// wired to:
822///
823/// - `llm` — `"none"` when no LLM is configured; bare `model` for
824///   Ollama backends (legacy banner shape); `backend:model` for
825///   every OpenAI-compatible vendor (xAI, OpenAI, Anthropic,
826///   Gemini, DeepSeek, Kimi, Qwen, Mistral, Groq, Together,
827///   Cerebras, OpenRouter, Fireworks, LMStudio, vLLM, llama.cpp).
828/// - `embedding` — `"none"` when the tier preset disables the
829///   embedder (`keyword` tier); otherwise the resolver's canonical
830///   model string.
831/// - `embedding_dim` — v0.7.x (issue #1169): sourced from
832///   [`ResolvedEmbeddings::embedding_dim`] when the resolver
833///   recognised the operator-picked model id (via the
834///   [`KNOWN_EMBEDDING_DIMS`] lookup); falls back to the tier preset
835///   ([`EmbeddingModel::dim`]) only when the operator's model is not
836///   in the table. Pre-#1169 this field was sourced ONLY from the
837///   tier preset, which silently drifted the moment an operator set
838///   `[embeddings].model` to anything outside the 2-family
839///   [`EmbeddingModel`] enum.
840/// - `cross_encoder` — `"none"` when neither the resolver nor the
841///   tier preset enables the cross-encoder; otherwise the
842///   resolver's model string.
843#[must_use]
844pub fn build_capability_models(tier: &TierConfig, models: &ResolvedModels) -> CapabilityModels {
845    let llm = if models.llm.model.is_empty() {
846        "none".to_string()
847    } else if models.llm.is_ollama_native() {
848        models.llm.model.clone()
849    } else {
850        models.llm.display_label()
851    };
852
853    let embedding = if tier.embedding_model.is_none() {
854        // Tier-preset disabled — keep the historical "none" sentinel
855        // even if a stale `[embeddings]` block remains in config.
856        "none".to_string()
857    } else {
858        models.embeddings.model.clone()
859    };
860
861    // v0.7.x (#1169) — resolver-side dim wins when known; tier preset
862    // is the back-compat fallback for unrecognised model ids and the
863    // tier-disabled-embedder posture (where the field stays 0 to match
864    // pre-#1169 semantics).
865    let embedding_dim = if tier.embedding_model.is_none() {
866        0
867    } else {
868        models.embeddings.embedding_dim.map_or_else(
869            || tier.embedding_model.map_or(0, EmbeddingModel::dim),
870            |d| d as usize,
871        )
872    };
873
874    let cross_encoder = if models.reranker.enabled || tier.cross_encoder {
875        models.reranker.model.clone()
876    } else {
877        "none".to_string()
878    };
879
880    CapabilityModels {
881        embedding,
882        embedding_dim,
883        llm,
884        cross_encoder,
885    }
886}
887
888/// Permissions block (capabilities schema v2). Pre-P4 reports a live
889/// count of namespace standards carrying a `metadata.governance` policy;
890/// the full enforcement gate lands in P4. The honesty patch (P1)
891/// renames the mode from `"ask"` (which implied an interactive prompt
892/// loop) to `"advisory"` (governance metadata is recorded but not
893/// enforced).
894#[derive(Debug, Clone, Serialize, Deserialize, Default)]
895pub struct CapabilityPermissions {
896    /// Enforcement mode. `"advisory"` until P4 ships the gate.
897    pub mode: String,
898    /// Number of namespace standards whose `metadata.governance` is
899    /// non-null. Counts policies, not memories.
900    pub active_rules: usize,
901    /// v0.7.0 K5: ordered list of one-line summaries — one entry per
902    /// active governance policy, sorted lexicographically by namespace.
903    /// Each entry names the namespace plus the policy's `write`,
904    /// `promote`, `delete`, `approver`, and `inherit` values so an
905    /// operator (or LLM) can see the live ruleset at a glance without
906    /// fanning out per-namespace `memory_namespace_get_standard` calls.
907    ///
908    /// **Wire shape.** `skip_serializing_if = "Vec::is_empty"` keeps the
909    /// field absent from v2 responses (which historically had no per-rule
910    /// serializer — the v0.6.3.1 honesty patch dropped the field from
911    /// the v2 wire entirely) when no policies are configured. v3 callers
912    /// see the field on every response with policies, matching the K5
913    /// spec contract that v3 brings the field back with a backing
914    /// implementation.
915    ///
916    /// Closes the v0.6.3.1 honest-Capabilities-v2 disclosure that this
917    /// field was a placeholder — the K5 increment ships the per-rule
918    /// serializer that was previously missing.
919    #[serde(default, skip_serializing_if = "Vec::is_empty")]
920    pub rule_summary: Vec<String>,
921    /// v0.6.3.1 (P4, audit G1): governance-inheritance posture.
922    /// `"enforced"` = `resolve_governance_policy` walks the namespace
923    /// chain leaf-first and returns the most-specific policy (with
924    /// `inherit: false` short-circuiting). Pre-v0.6.3.1 was
925    /// `"display_only"` — the UI surfaced the chain but the gate
926    /// consulted only the leaf, leaving children of governed parents
927    /// completely ungoverned. The field is `Option<String>` so older
928    /// capabilities responses (without the field) round-trip cleanly
929    /// via `#[serde(default)]`.
930    #[serde(default)]
931    pub inheritance: Option<String>,
932    /// v0.7.0 K3: per-mode decision counts since process start. Lets
933    /// operators verify the gate is actually being consulted and spot
934    /// drift between advertised policy and enforced policy. `None` on
935    /// older responses (`#[serde(default)]` round-trips cleanly).
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub decision_counts: Option<PermissionsDecisionCounts>,
938}
939
940/// Hook-pipeline block (capabilities schema v2). Pre-v0.7 reports webhook
941/// subscriptions as the closest analogue. The full hook pipeline lands in
942/// v0.7 Bucket 0 (arch-enhancement-spec §2).
943#[derive(Debug, Clone, Serialize, Deserialize)]
944pub struct CapabilityHooks {
945    /// Number of registered hook subscribers (proxy: webhook subscriptions).
946    pub registered_count: usize,
947    // P1 honesty patch: `by_event` was always an empty map — no event
948    // registry exists. Dropped from the v2 wire schema.
949    /// v0.6.3.1 P5 (G9): canonical list of webhook event types the
950    /// daemon emits. Integrators pin the `subscribe(event_types: …)`
951    /// filter against these strings. Always populated so downstream
952    /// callers do not have to handle a missing field.
953    #[serde(default = "default_webhook_events")]
954    pub webhook_events: Vec<String>,
955    /// v0.7.0 L1-7: total number of distinct `HookEvent` variants the
956    /// pipeline supports.  Populated from the compile-time constant
957    /// [`HOOK_EVENTS_COUNT`] so operators and integrations can verify
958    /// they are running against the expected pipeline version without
959    /// enumerating the enum.
960    ///
961    /// History: G2 shipped 20; G10 added the 21st; Task 6/8 added
962    /// the 22nd + 23rd; L1-7 adds the 24th + 25th → total **25**.
963    #[serde(default = "default_hook_events_count")]
964    pub hook_events_count: usize,
965    /// v0.7-polish SEC-15 / COR-11 (issue #780): mirror of the
966    /// process-wide
967    /// `crate::metrics::auto_export_spawn_failed_total` counter.
968    /// Non-zero means at least one `post_reflect.auto_export` detached
969    /// worker panicked or returned `Err` since process start — the
970    /// reflection is committed in the DB but its on-disk markdown/json
971    /// artefact did NOT land. Operators alert on a non-zero value
972    /// without scraping `/metrics` directly.
973    ///
974    /// `skip_serializing_if = is_zero_u64` keeps healthy daemons'
975    /// capabilities responses byte-identical to pre-#780 — only
976    /// daemons that have actually hit the failure path see the field
977    /// on the wire. The MCP/HTTP capabilities builder overlays the
978    /// live value at response time.
979    #[serde(default, skip_serializing_if = "is_zero_u64")]
980    pub auto_export_spawn_failed_total: u64,
981}
982
983/// Compile-time count of `HookEvent` variants.  Updated here when new
984/// variants land; the corresponding enum exhaustiveness check in
985/// `src/hooks/timeouts.rs` enforces the count at test time.
986pub const HOOK_EVENTS_COUNT: usize = 25;
987
988fn default_hook_events_count() -> usize {
989    HOOK_EVENTS_COUNT
990}
991
992impl Default for CapabilityHooks {
993    fn default() -> Self {
994        Self {
995            registered_count: 0,
996            webhook_events: default_webhook_events(),
997            hook_events_count: HOOK_EVENTS_COUNT,
998            auto_export_spawn_failed_total: 0,
999        }
1000    }
1001}
1002
1003/// Default webhook events list — kept in sync with
1004/// `crate::subscriptions::WEBHOOK_EVENT_TYPES`. The constant lives in
1005/// `subscriptions.rs` (the surface that uses it at runtime); this
1006/// helper exists so `serde(default = …)` and `CapabilityHooks::default`
1007/// can fill the field without a cross-module dep on `subscriptions`.
1008///
1009/// v0.7.0 K4 — `approval_requested` joined the canonical list. The
1010/// `webhook_events` capability surface is the integration contract
1011/// for K10's Approval API HTTP+SSE handler; surfacing the event type
1012/// here closes the v0.6.3.1 honest-disclosure that the
1013/// `approval.subscribers` field was advertised but unwired.
1014fn default_webhook_events() -> Vec<String> {
1015    // v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): the
1016    // three entries that ARE MCP tool names route through the
1017    // canonical `tool_names` consts; the remaining four are
1018    // subscription-event types (different namespace) and stay raw.
1019    use crate::mcp::registry::tool_names as tn;
1020    vec![
1021        tn::MEMORY_STORE.to_string(),
1022        tn::MEMORY_PROMOTE.to_string(),
1023        tn::MEMORY_DELETE.to_string(),
1024        crate::subscriptions::webhook_events::MEMORY_LINK_CREATED.to_string(),
1025        crate::subscriptions::webhook_events::MEMORY_LINK_INVALIDATED.to_string(),
1026        crate::subscriptions::webhook_events::MEMORY_CONSOLIDATED.to_string(),
1027        crate::subscriptions::webhook_events::APPROVAL_REQUESTED.to_string(),
1028    ]
1029}
1030
1031/// Compaction block (capabilities schema v2). v0.8 Pillar 2.5 work —
1032/// reports `{planned, version, enabled}` plus optional run stats. The
1033/// honesty patch (P1) replaced the bare `enabled: false` with the
1034/// planned-feature shape so operators can distinguish "feature exists
1035/// but disabled" from "feature not in this build".
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1037pub struct CapabilityCompaction {
1038    /// Planned-feature marker. `planned = true` while compaction lives
1039    /// only on the roadmap. When the subsystem ships the daemon will
1040    /// flip `planned = false` and `enabled` will reflect runtime state.
1041    #[serde(flatten)]
1042    pub status: PlannedFeature,
1043    /// Once shipped: scheduled compaction interval in minutes.
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub interval_minutes: Option<u64>,
1046    /// Once shipped: timestamp of the most recent compaction run.
1047    #[serde(default, skip_serializing_if = "Option::is_none")]
1048    pub last_run_at: Option<String>,
1049    /// Once shipped: arbitrary JSON describing the most recent run.
1050    #[serde(default, skip_serializing_if = "Option::is_none")]
1051    pub last_run_stats: Option<serde_json::Value>,
1052}
1053
1054impl CapabilityCompaction {
1055    /// Pre-v0.8 zero-state: planned, not enabled.
1056    #[must_use]
1057    pub fn planned() -> Self {
1058        Self {
1059            status: PlannedFeature::planned("v0.8+"),
1060            interval_minutes: None,
1061            last_run_at: None,
1062            last_run_stats: None,
1063        }
1064    }
1065}
1066
1067impl Default for CapabilityCompaction {
1068    fn default() -> Self {
1069        Self::planned()
1070    }
1071}
1072
1073/// Approval-API block (capabilities schema v2). `pending_requests`
1074/// counts the existing `pending_actions` table (live signal).
1075#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1076pub struct CapabilityApproval {
1077    /// Live count of `pending_actions` with status='pending'.
1078    pub pending_requests: usize,
1079    // P1 honesty patch: `subscribers` (no subscription API exists) and
1080    // `default_timeout_seconds` (no sweeper enforces timeouts) dropped
1081    // from the v2 wire schema.
1082    /// v0.7.0 Cluster-C SEC-3 (issue #767) — live count of rows in
1083    /// `signed_events_dlq` (the deferred-audit drainer's dead-letter
1084    /// queue). Non-zero means at least one storage-hook
1085    /// `governance.refusal` event failed to chain-log into
1086    /// `signed_events` and landed in the DLQ for operator replay.
1087    /// Default-omitted from the wire when zero so existing dashboards
1088    /// see no churn on healthy daemons.
1089    #[serde(default, skip_serializing_if = "is_zero_u64")]
1090    pub deferred_audit_dlq_size: u64,
1091}
1092
1093/// Sidechain-transcript block (capabilities schema v2). v0.7 Bucket 1.7
1094/// work — reports `{planned, version, enabled}` until the subsystem
1095/// ships. The honesty patch (P1) replaced the bare `enabled: false`
1096/// with the planned-feature shape.
1097#[derive(Debug, Clone, Serialize, Deserialize)]
1098pub struct CapabilityTranscripts {
1099    /// Planned-feature marker. `planned = true` while sidechain
1100    /// transcripts live only on the roadmap.
1101    #[serde(flatten)]
1102    pub status: PlannedFeature,
1103    /// Once shipped: number of stored transcripts.
1104    #[serde(default, skip_serializing_if = "is_zero_usize")]
1105    pub total_count: usize,
1106    /// Once shipped: total transcript storage in megabytes.
1107    #[serde(default, skip_serializing_if = "is_zero_u64")]
1108    pub total_size_mb: u64,
1109}
1110
1111impl CapabilityTranscripts {
1112    /// Pre-v0.7 zero-state: planned, not enabled. Retained for the
1113    /// pre-build capability surface used by the bootstrap config; the
1114    /// MCP / HTTP overlay flips this to [`Self::shipped`] before the
1115    /// report goes on the wire at v0.7.0+.
1116    #[must_use]
1117    pub fn planned() -> Self {
1118        Self {
1119            status: PlannedFeature::planned("v0.7+"),
1120            total_count: 0,
1121            total_size_mb: 0,
1122        }
1123    }
1124
1125    /// v0.7.0 #1324 — the substrate ships at v0.7.0: zstd-3 BLOB
1126    /// store, `memory_transcripts` table, `memory_transcript_links`
1127    /// join, `replay_transcript_union` walk, the `memory_replay` MCP
1128    /// tool, and the per-namespace lifecycle sweep are all on disk.
1129    /// Operators flip `enabled: true` by wiring the R5 reference
1130    /// `pre_store` hook (`tools/transcript-extractor/`) — the
1131    /// substrate cannot link transcripts without an operator-driven
1132    /// extraction path, so this constructor reflects "shipped but
1133    /// awaiting per-namespace opt-in." The live MCP / HTTP overlay
1134    /// can additionally flip `enabled` when it observes a non-zero
1135    /// transcript count (operator opt-in is observed indirectly via
1136    /// presence of rows).
1137    ///
1138    /// Returning `planned: false` here closes the v0.7.0 honesty drift
1139    /// — the pre-#1324 surface advertised `planned: true` even after
1140    /// the substrate landed, which confused operators reading the
1141    /// capabilities surface as a feature-availability oracle.
1142    #[must_use]
1143    pub fn shipped() -> Self {
1144        Self {
1145            status: PlannedFeature {
1146                planned: false,
1147                version: crate::PKG_VERSION.to_string(),
1148                enabled: false,
1149            },
1150            total_count: 0,
1151            total_size_mb: 0,
1152        }
1153    }
1154}
1155
1156impl Default for CapabilityTranscripts {
1157    fn default() -> Self {
1158        Self::planned()
1159    }
1160}
1161
1162#[allow(clippy::trivially_copy_pass_by_ref)]
1163fn is_zero_usize(n: &usize) -> bool {
1164    *n == 0
1165}
1166
1167#[allow(clippy::trivially_copy_pass_by_ref)]
1168fn is_zero_u64(n: &u64) -> bool {
1169    *n == 0
1170}
1171
1172/// HNSW vector-index health (capabilities schema v2, v0.6.3.1 P3).
1173///
1174/// Closes the G2 audit gap by surfacing both the cumulative oldest-eviction
1175/// count and a rolling-window flag so operators can distinguish "this
1176/// process has hit the cap once, long ago" from "we are currently
1177/// sustained at the cap and shedding embeddings now". Both numbers are
1178/// process-local — the index itself resets on restart so persistence
1179/// would be misleading.
1180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1181pub struct CapabilityHnsw {
1182    /// Cumulative count of vectors evicted by the `MAX_ENTRIES`-cap path
1183    /// since this process started.
1184    pub evictions_total: u64,
1185    /// True when at least one eviction has occurred in the last 60 s.
1186    /// Lets dashboards alert on *active* pressure rather than only the
1187    /// historical counter.
1188    pub evicted_recently: bool,
1189}
1190
1191// ---------------------------------------------------------------------------
1192// Capabilities v3 L3-5 — recursive-learning / skills / forensic / governance
1193// blocks. v3-only (additive over v2). Every field is hand-mapped to a
1194// concrete implementation that landed in the v0.7.0 grand-slam L1+L2 waves
1195// so an external auditor can trace a claim back to a source-code line.
1196// ---------------------------------------------------------------------------
1197
1198/// v0.7.0 L3-5 — substrate-native reflection capability surface.
1199///
1200/// Every field MUST map to a real implementation. Audit anchors:
1201///
1202/// - `implemented`: [`crate::storage::reflect::reflect`] +
1203///   [`crate::mcp::tools::memory_reflect`] (issue #655 Task 4/8,
1204///   commit `3dc76f3`).
1205/// - `depth_bounded`: depth-cap check in [`crate::storage::reflect`]
1206///   step 5; [`crate::errors::MemoryError::ReflectionDepthExceeded`]
1207///   surfaces refusal with `attempted` + `cap` + `namespace`.
1208/// - `max_default`: compiled-in default returned by
1209///   [`crate::models::namespace::GovernancePolicy::effective_max_reflection_depth`]
1210///   (currently **3**) when the namespace's
1211///   `metadata.governance.max_reflection_depth` is unset.
1212/// - `attestation`: every reflection writes a `signed_events` row via
1213///   [`crate::signed_events::append_signed_event`]; the project uses
1214///   Ed25519 (see [`crate::identity::sign`] H2 + H4 link-signing
1215///   plus the operator-signed governance rules in
1216///   [`crate::governance::rules_store`]).
1217/// - `curator_mode`: implemented in
1218///   [`crate::curator::reflection_pass`] and the
1219///   `ai-memory curator --reflection-pass` CLI verb in
1220///   [`crate::cli::curator`].
1221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1222pub struct CapabilityReflection {
1223    /// `true` whenever the reflection primitive is wired (memory_reflect MCP
1224    /// tool present + `storage::reflect::reflect` callable). False is reserved
1225    /// for a build that compiled the field out.
1226    pub implemented: bool,
1227    /// `true` when reflections are subject to a depth cap that refuses
1228    /// further reflection past the configured maximum.
1229    pub depth_bounded: bool,
1230    /// Compiled-in default cap returned when no namespace policy is set.
1231    /// Tracks [`crate::models::namespace::GovernancePolicy::effective_max_reflection_depth`].
1232    pub max_default: u32,
1233    /// Signature algorithm used by the substrate for attested events
1234    /// touching reflections (link signatures + `signed_events` rows).
1235    pub attestation: String,
1236    /// `"implemented"` when the curator reflection pass is wired
1237    /// (`curator::reflection_pass` + `ai-memory curator` CLI). Stays a
1238    /// string (not a bool) so future increments can grow new values like
1239    /// `"scheduled"` without a wire-shape break.
1240    pub curator_mode: String,
1241}
1242
1243impl CapabilityReflection {
1244    /// Build the L3-5 reflection capability from real values pinned at
1245    /// compile time so the wire shape reflects what this binary actually
1246    /// ships. Constants from [`crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP`]
1247    /// and the curator module are consulted directly — no magic strings.
1248    #[must_use]
1249    pub fn current() -> Self {
1250        Self {
1251            implemented: true,
1252            depth_bounded: true,
1253            max_default: crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP,
1254            attestation: "Ed25519".to_string(),
1255            // #1672 — the curator reflection pass (`curator --reflect`) is
1256            // `#[cfg(feature = "sal")]`-gated; on the default sqlite-bundled
1257            // build it hard-bails (`cli/curator.rs:548-560`), so reporting
1258            // `"implemented"` over-reports the surface. Gate the honest value.
1259            curator_mode: if cfg!(feature = "sal") {
1260                IMPLEMENTED.to_string()
1261            } else {
1262                CURATOR_MODE_REQUIRES_SAL.to_string()
1263            },
1264        }
1265    }
1266}
1267
1268fn default_capability_reflection() -> CapabilityReflection {
1269    CapabilityReflection::current()
1270}
1271
1272/// v0.7.0 L3-5 — Agent-Skills capability surface.
1273///
1274/// Every field MUST map to a real implementation:
1275///
1276/// - `implemented`: 7 MCP tools wired in
1277///   [`crate::mcp::registry`] + handlers in
1278///   [`crate::mcp::tools::skill_*`].
1279/// - `standard`: the parser in [`crate::parsing::skill_md`] validates
1280///   names + frontmatter against the agentskills.io §3.1/§3.2 spec.
1281/// - `tools`: list mirrors the registered handler names verbatim;
1282///   regression test [`SKILL_TOOL_NAMES`] verifies the slice matches
1283///   the live MCP dispatcher.
1284/// - `round_trip`: `memory_skill_register` → `memory_skill_export` →
1285///   re-register produces the IDENTICAL SHA-256 digest (see
1286///   `tests/skill_test.rs`, the round-trip pin).
1287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1288pub struct CapabilitySkills {
1289    /// `true` whenever the skill registration + lookup substrate is
1290    /// wired. False is reserved for a build that compiled the family out.
1291    pub implemented: bool,
1292    /// External spec the parser targets. `"agentskills.io"` is the
1293    /// canonical name documented in the L1-5 spec.
1294    pub standard: String,
1295    /// Canonical list of registered skill tools. Order matches the MCP
1296    /// dispatch order so an LLM that pins the order doesn't drift.
1297    pub tools: Vec<String>,
1298    /// `"verified"` when register → export → re-register is exercised in
1299    /// the test suite and the digests match.
1300    pub round_trip: String,
1301}
1302
1303/// Canonical skill tool names as registered in
1304/// [`crate::mcp::registry`]. Pinned here (not derived from the registry)
1305/// so the capability surface remains a stable, declarative contract;
1306/// the regression test
1307/// `cap_v3_l3_5_skill_tools_match_registered_mcp_dispatch` ensures the
1308/// two stay in sync.
1309// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — each
1310// entry routes through the canonical `tool_names` const so this
1311// capability surface cannot drift from the dispatch table in name
1312// spelling. The `cap_v3_l3_5_skill_tools_match_registered_mcp_dispatch`
1313// regression test continues to enforce membership equality between
1314// this slice and the registered set.
1315pub const SKILL_TOOL_NAMES: &[&str] = &[
1316    crate::mcp::registry::tool_names::MEMORY_SKILL_REGISTER,
1317    crate::mcp::registry::tool_names::MEMORY_SKILL_LIST,
1318    crate::mcp::registry::tool_names::MEMORY_SKILL_GET,
1319    crate::mcp::registry::tool_names::MEMORY_SKILL_RESOURCE,
1320    crate::mcp::registry::tool_names::MEMORY_SKILL_EXPORT,
1321    crate::mcp::registry::tool_names::MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
1322    crate::mcp::registry::tool_names::MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
1323];
1324
1325impl CapabilitySkills {
1326    /// Build the L3-5 skills capability from real, code-anchored values.
1327    #[must_use]
1328    pub fn current() -> Self {
1329        Self {
1330            implemented: true,
1331            standard: "agentskills.io".to_string(),
1332            tools: SKILL_TOOL_NAMES.iter().map(|s| (*s).to_string()).collect(),
1333            round_trip: "verified".to_string(),
1334        }
1335    }
1336}
1337
1338fn default_capability_skills() -> CapabilitySkills {
1339    CapabilitySkills::current()
1340}
1341
1342/// Capability-matrix value string — a surface is reported as
1343/// `"implemented"` once its engine/hook/wrapper code is live. One named
1344/// const so the 18 matrix cells share a single spelling (pm-v3.1
1345/// hardcoded-literal gate, #1558 wave 4).
1346const IMPLEMENTED: &str = "implemented";
1347
1348/// #1672 — honest `curator_mode` value on non-`sal` builds, where the curator
1349/// reflection pass (`curator --reflect`) is compiled to a hard-bail companion.
1350const CURATOR_MODE_REQUIRES_SAL: &str = "requires_sal_feature";
1351
1352/// v0.7.0 L3-5 — forensic-evidence capability surface.
1353///
1354/// Each label names a CLI / function pair that **exists** in this binary:
1355///
1356/// - `verify_reflection_chain`: `ai-memory verify-reflection-chain` —
1357///   driver lives in [`crate::cli::verify`].
1358/// - `export_forensic_bundle`: `ai-memory export-forensic-bundle` —
1359///   builder lives in [`crate::forensic::bundle::build`].
1360/// - `verify_forensic_bundle`: `ai-memory verify-forensic-bundle` —
1361///   verifier lives in [`crate::forensic::bundle::verify`].
1362///
1363/// All three are `"implemented"` strings (not bools) so future
1364/// increments can promote a value to `"attested"` or `"scheduled"`
1365/// without a wire-shape break.
1366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1367pub struct CapabilityForensic {
1368    pub verify_reflection_chain: String,
1369    pub export_forensic_bundle: String,
1370    pub verify_forensic_bundle: String,
1371}
1372
1373impl CapabilityForensic {
1374    /// Build the L3-5 forensic capability — all three driver paths are
1375    /// wired in this build.
1376    #[must_use]
1377    pub fn current() -> Self {
1378        Self {
1379            verify_reflection_chain: IMPLEMENTED.to_string(),
1380            export_forensic_bundle: IMPLEMENTED.to_string(),
1381            verify_forensic_bundle: IMPLEMENTED.to_string(),
1382        }
1383    }
1384}
1385
1386fn default_capability_forensic() -> CapabilityForensic {
1387    CapabilityForensic::current()
1388}
1389
1390/// v0.7.0 L3-5 — substrate-rules governance capability surface.
1391///
1392/// Surfaces the L1-6 activation posture honestly:
1393///
1394/// - `rules_engine`: `"operator_signed"` because the L1-6 loader
1395///   refuses to honour any `enabled = 1` rule that is not
1396///   `attest_level = 'operator_signed'` and whose signature does not
1397///   verify against the active operator pubkey
1398///   ([`crate::governance::rules_store`] L1-6 audit).
1399/// - `enforced_actions`: the actual variant set in
1400///   [`crate::governance::agent_action::AgentAction`] minus the
1401///   `Custom` extension point (extension points are not
1402///   substrate-enforced). v0.7.0 ships **four** action kinds at the
1403///   harness-mediated PreToolUse boundary.
1404/// - `bypass_impossibility_tests`: count of `#[test]` functions in
1405///   [`tests/governance_l16_activation.rs`] verifying the
1406///   bypass-impossibility properties (signature-required, tampered-sig
1407///   rejected, direct-enabled-flip rejected, keygen 0600, idempotent
1408///   sign-seed, rotated-key invalidates). The number reflects the test
1409///   file as of v0.7.0 — bumping it requires an audit pass and a
1410///   matching test addition.
1411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1412pub struct CapabilityGovernance {
1413    pub rules_engine: String,
1414    pub enforced_actions: Vec<String>,
1415    pub bypass_impossibility_tests: u32,
1416    /// v0.7.0 SEC-2 (Cluster D, issue #767) — `true` when an operator
1417    /// pubkey is resolved (env var or `~/.config/ai-memory/operator.key.pub`)
1418    /// AND therefore the L1-6 loader is in attest-enforcing mode (every
1419    /// `enabled = 1` row MUST be operator-signed to fire). `false` when
1420    /// the substrate is in pre-L1-6 / fail-OPEN compat mode — every
1421    /// enabled rule passes through without signature verification.
1422    ///
1423    /// Clients that need to display the deployment's enforcement
1424    /// posture (operator dashboard, MCP-inspect tool, capabilities
1425    /// summary) can render this flag verbatim. Defaults to `false`
1426    /// for envelopes serialised before SEC-2 to preserve wire
1427    /// compatibility.
1428    #[serde(default)]
1429    pub l1_6_attest: bool,
1430}
1431
1432/// v0.7.0 L1-6 — the canonical agent-external action kinds the
1433/// substrate gates via the operator-signed rules engine. Matches the
1434/// variant set in [`crate::governance::agent_action::AgentAction`]
1435/// (minus the open-ended `Custom` extension point).
1436///
1437/// #1605 — the values are the snake_case **wire tags** from
1438/// [`crate::governance::agent_action::action_kinds`] (the #1558 SSOT
1439/// the `memory_check_agent_action` MCP parser, the CLI `rules test`
1440/// parser, and the `governance_rules.kind` column all share), NOT the
1441/// Rust variant names. The pre-#1605 list advertised `"Bash"` /
1442/// `"FilesystemWrite"` / … — tokens the kind parser refuses — so a
1443/// caller following capabilities verbatim got `unknown kind`.
1444///
1445/// MemoryWrite is intentionally NOT in this list — substrate-internal
1446/// memory writes are gated by the K9 `Op` pipeline
1447/// ([`crate::governance::Op`]) which is a separate, substrate-
1448/// authoritative surface. The two engines have different enforcement
1449/// semantics; honest reporting keeps them on separate fields rather
1450/// than conflating them under one label. The L3-5 audit comment in
1451/// `tests/capabilities_v3_l3_5.rs` documents the carry-forward.
1452pub const ENFORCED_AGENT_ACTIONS: &[&str] = &[
1453    crate::governance::agent_action::action_kinds::BASH,
1454    crate::governance::agent_action::action_kinds::FILESYSTEM_WRITE,
1455    crate::governance::agent_action::action_kinds::NETWORK_REQUEST,
1456    crate::governance::agent_action::action_kinds::PROCESS_SPAWN,
1457];
1458
1459/// v0.7.0 L1-6 — number of bypass-impossibility tests pinning the
1460/// rules-engine activation posture. Tracks the `#[test]` count in
1461/// `tests/governance_l16_activation.rs`. Bumping this requires both an
1462/// audit and a matching test landing in that file.
1463pub const GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS: u32 = 6;
1464
1465impl CapabilityGovernance {
1466    /// Build the L3-5 governance capability from the live constants.
1467    #[must_use]
1468    pub fn current() -> Self {
1469        Self {
1470            rules_engine: "operator_signed".to_string(),
1471            enforced_actions: ENFORCED_AGENT_ACTIONS
1472                .iter()
1473                .map(|s| (*s).to_string())
1474                .collect(),
1475            bypass_impossibility_tests: GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS,
1476            // SEC-2 — reflect the live pubkey-resolution state at
1477            // envelope construction time. The pubkey lookup is
1478            // filesystem + env; cheap relative to the rest of the
1479            // capabilities-v3 build path.
1480            l1_6_attest: crate::governance::rules_store::l1_6_attest_active(),
1481        }
1482    }
1483}
1484
1485fn default_capability_governance() -> CapabilityGovernance {
1486    CapabilityGovernance::current()
1487}
1488
1489/// v0.7.0 WT-1-G — atomisation capability surface.
1490///
1491/// WT-1 ships substrate-native decomposition of long memories into
1492/// atomic propositions. The parent memory is archived (`archived_at`
1493/// stamped, `atomised_into = N`) and `N` first-class atomic children
1494/// land with `atom_of` back-pointers and a signed `derives_from`
1495/// `MemoryLink`. Each sub-field below names a real operator-facing
1496/// surface in this binary; the round-trip is honest — the values are
1497/// `"implemented"` only when the engine, hook, and wrapper code are
1498/// all wired.
1499///
1500/// Field → implementation anchor map:
1501///
1502/// - `tool`: MCP `memory_atomise` (Family::Power). Defined in
1503///   [`crate::mcp::tools::atomise`] + registered in
1504///   [`crate::mcp::registry`]. WT-1-C landed it.
1505/// - `cli`: `ai-memory atomise <memory_id>` subcommand. Wrapper lives
1506///   in [`crate::cli::commands::atomise`]. WT-1-F landed it.
1507/// - `auto`: namespace-policy-gated `auto_atomise` pre_store hook.
1508///   The hook in [`crate::hooks::pre_store::auto_atomise`] is
1509///   non-blocking (detached worker thread) and fires only when the
1510///   namespace standard's `metadata.governance.auto_atomise = true`.
1511///   WT-1-D landed it.
1512/// - `recall_preference`: recall surfaces atoms in place of an
1513///   archived parent via the SQL guard
1514///   `AND NOT (archived_at IS NOT NULL AND atomised_into > 0)`.
1515///   WT-1-E landed it.
1516/// - `forensic`: forensic bundle export includes the parent → atoms
1517///   chain envelope so a downstream auditor reconstructs the
1518///   decomposition offline. WT-1-E landed it.
1519/// - `curator`: production `LlmCurator` uses the Gemma 4 prompt
1520///   with `tiktoken-rs::cl100k_base` token-budget validation and
1521///   the audit-honest STOP discipline (no retry after a parse-OK
1522///   verdict). WT-1-B landed it.
1523#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1524pub struct CapabilityAtomisation {
1525    /// MCP `memory_atomise` tool — `"implemented"` once the tool is
1526    /// registered and the [`crate::mcp::tools::atomise`] handler is
1527    /// wired against [`crate::atomisation::Atomiser`].
1528    pub tool: String,
1529    /// `ai-memory atomise` CLI subcommand — `"implemented"` once the
1530    /// wrapper in [`crate::cli::commands::atomise`] is dispatched
1531    /// from `daemon_runtime::Command::Atomise`.
1532    pub cli: String,
1533    /// Namespace-policy-gated auto-atomisation pre_store hook —
1534    /// `"implemented"` when [`crate::hooks::pre_store::auto_atomise`]
1535    /// is compiled and the store handlers call
1536    /// `maybe_enqueue_auto_atomise` after a successful insert.
1537    pub auto: String,
1538    /// Recall-time atom preference — `"implemented"` when the recall
1539    /// SQL carries the
1540    /// `AND NOT (archived_at IS NOT NULL AND atomised_into > 0)`
1541    /// guard so atomised parents stop surfacing in their atoms'
1542    /// place. WT-1-E.
1543    pub recall_preference: String,
1544    /// Forensic chain envelope — `"implemented"` when the forensic
1545    /// bundle exporter ([`crate::forensic::bundle::build`]) walks
1546    /// `atom_of` back-pointers to include the parent → atoms chain
1547    /// in the bundle. WT-1-E.
1548    pub forensic: String,
1549    /// LLM curator — `"implemented"` once
1550    /// [`crate::atomisation::curator::LlmCurator`] is the production
1551    /// `Curator` impl driving the atomisation engine (Gemma 4 prompt,
1552    /// tiktoken-rs cl100k token-budget validation, audit-honest STOP).
1553    /// WT-1-B.
1554    pub curator: String,
1555    /// Memory-link relation that anchors the atom → parent edge.
1556    /// Always `"derives_from"`, matching
1557    /// [`crate::models::MemoryLinkRelation::DerivesFrom`]. Distinct
1558    /// from `related_to` / `supersedes` / `contradicts` — the
1559    /// atomisation engine writes this edge specifically, and
1560    /// downstream consumers can filter on the relation to walk
1561    /// decomposition lineage without reflection-chain noise.
1562    pub link_relation: String,
1563}
1564
1565impl CapabilityAtomisation {
1566    /// Build the WT-1-G atomisation capability surface from real,
1567    /// code-anchored values. Every `"implemented"` here is a claim
1568    /// pinned by [`tests/capabilities_v3_l3_5.rs`] and walked back to
1569    /// a registered MCP tool / CLI verb / hook module / SQL guard.
1570    #[must_use]
1571    pub fn current() -> Self {
1572        Self {
1573            tool: IMPLEMENTED.to_string(),
1574            cli: IMPLEMENTED.to_string(),
1575            auto: IMPLEMENTED.to_string(),
1576            recall_preference: IMPLEMENTED.to_string(),
1577            forensic: IMPLEMENTED.to_string(),
1578            curator: IMPLEMENTED.to_string(),
1579            link_relation: "derives_from".to_string(),
1580        }
1581    }
1582}
1583
1584fn default_capability_atomisation() -> CapabilityAtomisation {
1585    CapabilityAtomisation::current()
1586}
1587
1588// ---------------------------------------------------------------------------
1589// v0.7.x Form 6 — MemoryKind Batman-vocabulary capability surface (#759)
1590// ---------------------------------------------------------------------------
1591
1592/// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1593/// capability surface. Names the recall-filter / auto-classify
1594/// surfaces shipped under Form 6.
1595///
1596/// Field → implementation anchor map:
1597///
1598/// - `vocabulary`: the complete enumerated vocabulary the substrate
1599///   accepts on the `memory_kind` column. Always
1600///   `["observation", "reflection", "persona", "concept", "entity",
1601///   "claim", "relation", "event", "conversation", "decision"]` in
1602///   v0.7.x — anchored at compile time by
1603///   [`crate::models::MemoryKind::all`].
1604/// - `recall_filter`: MCP `memory_recall` and HTTP recall accept a
1605///   `kinds` parameter (CSV string or JSON array). `"implemented"`
1606///   once the param is plumbed into [`crate::mcp::tools::recall`]
1607///   and [`crate::handlers::http::recall_response`].
1608/// - `cli_filter`: `ai-memory recall --kind concept,entity` CLI
1609///   flag. `"implemented"` once the flag is wired in
1610///   [`crate::cli::recall::RecallArgs`].
1611/// - `auto_classify`: the namespace-policy-gated
1612///   `pre_store::auto_classify_kind` hook. `"implemented"` once
1613///   the hook module is compiled and `memory_store` calls
1614///   [`crate::hooks::pre_store::maybe_auto_classify`] after policy
1615///   resolution.
1616/// - `auto_classify_modes`: enumerated policy modes the operator
1617///   may set. Always `["off", "regex_only", "regex_then_llm"]` —
1618///   anchored against [`crate::models::MemoryKindAutoClassify`].
1619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1620pub struct CapabilityMemoryKindVocab {
1621    /// Complete enumerated vocabulary the substrate accepts on the
1622    /// `memory_kind` column. Compile-anchored.
1623    pub vocabulary: Vec<String>,
1624    /// MCP `memory_recall` + HTTP recall `kinds` param wiring.
1625    pub recall_filter: String,
1626    /// CLI `--kind` flag wiring.
1627    pub cli_filter: String,
1628    /// Namespace-policy-gated auto-classify pre_store hook wiring.
1629    pub auto_classify: String,
1630    /// Enumerated auto-classify policy modes (`off` / `regex_only` /
1631    /// `regex_then_llm`). Compile-anchored.
1632    pub auto_classify_modes: Vec<String>,
1633}
1634
1635impl CapabilityMemoryKindVocab {
1636    /// Build the Form 6 memory-kind-vocab capability surface from
1637    /// real, code-anchored values. Every `"implemented"` here is a
1638    /// claim pinned by [`tests/form_6_memorykind_vocab.rs`].
1639    #[must_use]
1640    pub fn current() -> Self {
1641        Self {
1642            vocabulary: crate::models::MemoryKind::all()
1643                .iter()
1644                .map(|k| k.as_str().to_string())
1645                .collect(),
1646            recall_filter: IMPLEMENTED.to_string(),
1647            cli_filter: IMPLEMENTED.to_string(),
1648            auto_classify: IMPLEMENTED.to_string(),
1649            auto_classify_modes: vec![
1650                "off".to_string(),
1651                "regex_only".to_string(),
1652                "regex_then_llm".to_string(),
1653            ],
1654        }
1655    }
1656}
1657
1658fn default_capability_memory_kind_vocab() -> CapabilityMemoryKindVocab {
1659    CapabilityMemoryKindVocab::current()
1660}
1661
1662// ---------------------------------------------------------------------------
1663// v0.7.0 Form 5 (issue #758) — auto-confidence + shadow-mode +
1664// calibration tooling capability surface.
1665// ---------------------------------------------------------------------------
1666
1667/// v0.7.0 Form 5 — operator-facing confidence-calibration capability
1668/// surface. Names every Form-5 substrate the binary actually ships:
1669///
1670/// - `auto_derive`: the [`crate::confidence::derive`] engine
1671///   (deterministic auto-confidence formula). Opt-in via
1672///   `AI_MEMORY_AUTO_CONFIDENCE=1` — the field reports `"implemented"`
1673///   because the engine compiles in unconditionally; the env-var gate
1674///   is the operator control plane.
1675/// - `shadow_mode`: the [`crate::confidence::shadow`] pipeline backed
1676///   by the `confidence_shadow_observations` table (schema v39 sqlite /
1677///   v38 postgres). Opt-in via `AI_MEMORY_CONFIDENCE_SHADOW=1`.
1678/// - `freshness_decay`: the [`crate::confidence::decay::decayed`]
1679///   exponential decay model. Opt-in via `AI_MEMORY_CONFIDENCE_DECAY=1`
1680///   or per-namespace `confidence_decay_half_life_days` policy.
1681/// - `calibration_cli`: the `ai-memory calibrate confidence
1682///   --from-shadow` driver verb that scans the observation table and
1683///   emits per-(namespace, source) baselines.
1684/// - `calibration_tool`: the `memory_calibrate_confidence` MCP tool
1685///   (Family::Power) — operator-callable equivalent of the CLI driver.
1686/// - `signals_schema`: the wire-shape discriminator for the JSON
1687///   envelope stored on `memories.confidence_signals`. Always
1688///   `"v1"` in v0.7.0 — bumped when the [`crate::models::ConfidenceSignals`]
1689///   struct gains a new field.
1690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1691pub struct CapabilityConfidenceCalibration {
1692    /// `"implemented"` once [`crate::confidence::derive`] is wired into
1693    /// the substrate (it compiles in regardless of feature flag).
1694    pub auto_derive: String,
1695    /// `"implemented"` once [`crate::confidence::shadow`] is wired
1696    /// (Form 5).
1697    pub shadow_mode: String,
1698    /// `"implemented"` once [`crate::confidence::decay`] is wired
1699    /// (Form 5).
1700    pub freshness_decay: String,
1701    /// `"implemented"` once the `ai-memory calibrate confidence` CLI
1702    /// driver registers under [`crate::cli`].
1703    pub calibration_cli: String,
1704    /// `"implemented"` once the `memory_calibrate_confidence` MCP
1705    /// tool registers under Family::Power.
1706    pub calibration_tool: String,
1707    /// Wire-shape discriminator for `memories.confidence_signals`.
1708    /// Always `"v1"` in v0.7.0.
1709    pub signals_schema: String,
1710    /// Default freshness-decay half-life (days). 30 in v0.7.0; tunable
1711    /// per namespace via the `confidence_decay_half_life_days` policy.
1712    pub default_half_life_days: f64,
1713    /// v0.7.0 Gap 4 (#887) — derived-tier thresholds. MCP callers
1714    /// reading this surface know how the substrate buckets the
1715    /// `confidence` real into `confirmed` / `likely` / `ambiguous`
1716    /// without re-deriving the breakpoints. Stable; bumping is a
1717    /// wire-level break (see [`crate::models::ConfidenceTier`]).
1718    /// `#[serde(default)]` keeps pre-Gap-4 capability consumers
1719    /// reading newer payloads from breaking.
1720    #[serde(default)]
1721    pub tier_thresholds: ConfidenceTierThresholds,
1722}
1723
1724impl CapabilityConfidenceCalibration {
1725    /// Build the Form 5 capability surface from real, code-anchored
1726    /// values. Every `"implemented"` here is a claim pinned by
1727    /// `tests/form_5_confidence_calibration.rs` and walked back to a
1728    /// registered MCP tool / CLI verb / module file.
1729    #[must_use]
1730    pub fn current() -> Self {
1731        Self {
1732            auto_derive: IMPLEMENTED.to_string(),
1733            shadow_mode: IMPLEMENTED.to_string(),
1734            freshness_decay: IMPLEMENTED.to_string(),
1735            calibration_cli: IMPLEMENTED.to_string(),
1736            calibration_tool: IMPLEMENTED.to_string(),
1737            signals_schema: "v1".to_string(),
1738            default_half_life_days: crate::confidence::DEFAULT_HALF_LIFE_DAYS,
1739            tier_thresholds: ConfidenceTierThresholds::default(),
1740        }
1741    }
1742}
1743
1744fn default_capability_confidence_calibration() -> CapabilityConfidenceCalibration {
1745    CapabilityConfidenceCalibration::current()
1746}
1747
1748// ---------------------------------------------------------------------------
1749// Capabilities v1 — legacy shape retained for backward compat
1750// ---------------------------------------------------------------------------
1751
1752/// Legacy (v1) capabilities shape — the structure shipped before the
1753/// v0.6.3.1 honesty patch. Returned only when a client opts in via
1754/// `Accept-Capabilities: v1` (HTTP) or the MCP `accept` argument set
1755/// to `"v1"`. Default response is v2.
1756///
1757/// The v1 schema is frozen — do not extend it. New fields go into v2
1758/// (see [`Capabilities`]).
1759#[derive(Debug, Clone, Serialize, Deserialize)]
1760pub struct CapabilitiesV1 {
1761    pub tier: String,
1762    pub version: String,
1763    pub features: CapabilityFeaturesV1,
1764    pub models: CapabilityModels,
1765}
1766
1767/// Legacy v1 feature-flag block. Notably, `memory_reflection` is a
1768/// `bool` here (it became a `PlannedFeature` object in v2).
1769#[allow(clippy::struct_excessive_bools)]
1770#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct CapabilityFeaturesV1 {
1772    pub keyword_search: bool,
1773    pub semantic_search: bool,
1774    pub hybrid_recall: bool,
1775    pub query_expansion: bool,
1776    pub auto_consolidation: bool,
1777    pub auto_tagging: bool,
1778    pub contradiction_analysis: bool,
1779    pub cross_encoder_reranking: bool,
1780    pub memory_reflection: bool,
1781    #[serde(default)]
1782    pub embedder_loaded: bool,
1783}
1784
1785impl Capabilities {
1786    /// Project the v2 report down to the legacy v1 shape. Used to
1787    /// honour `Accept-Capabilities: v1` from older clients.
1788    ///
1789    /// `memory_reflection` collapses from `{planned, enabled}` to a
1790    /// single bool (`enabled` value). All v2-only fields
1791    /// (`recall_mode_active`, `reranker_active`, `permissions`,
1792    /// `hooks`, `compaction`, `approval`, `transcripts`) are dropped.
1793    #[must_use]
1794    pub fn to_v1(&self) -> CapabilitiesV1 {
1795        CapabilitiesV1 {
1796            tier: self.tier.clone(),
1797            version: self.version.clone(),
1798            features: CapabilityFeaturesV1 {
1799                keyword_search: self.features.keyword_search,
1800                semantic_search: self.features.semantic_search,
1801                hybrid_recall: self.features.hybrid_recall,
1802                query_expansion: self.features.query_expansion,
1803                auto_consolidation: self.features.auto_consolidation,
1804                auto_tagging: self.features.auto_tagging,
1805                contradiction_analysis: self.features.contradiction_analysis,
1806                cross_encoder_reranking: self.features.cross_encoder_reranking,
1807                memory_reflection: self.features.memory_reflection.enabled,
1808                embedder_loaded: self.features.embedder_loaded,
1809            },
1810            models: self.models.clone(),
1811        }
1812    }
1813
1814    /// v0.7.0 (A1+A2+A3+A4): project the report into the v3 shape.
1815    ///
1816    /// v3 = v2 +
1817    ///   - top-level `summary` (A1) — terse description of operational
1818    ///     access plus the three named recovery paths.
1819    ///   - top-level `to_describe_to_user` (A2) — plain-English
1820    ///     end-user-facing sentence the LLM should repeat verbatim
1821    ///     when asked "what tools do you have?". No MCP jargon.
1822    ///   - top-level `tools` (A3) — per-tool array carrying name,
1823    ///     family, `loaded`, and `callable_now`. `callable_now`
1824    ///     combines profile-side loaded-state with the
1825    ///     `[mcp.allowlist]` agent-can-call decision so an LLM that
1826    ///     keeps a manifest cache doesn't need to ask twice to know
1827    ///     whether a tool will resolve.
1828    ///   - top-level `agent_permitted_families` (A4, optional) — when
1829    ///     the `[mcp.allowlist]` is enabled AND an `agent_id` is
1830    ///     provided, lists the family names the requesting agent is
1831    ///     allowed to access (collapses every callable_now=true entry's
1832    ///     family to a unique list). When the allowlist is disabled or
1833    ///     no agent_id is provided, the field is omitted from the wire
1834    ///     (so v2-shaped consumers see no churn from A4 alone).
1835    ///
1836    /// All four are computed by the caller from the live `Profile` +
1837    /// `McpConfig` + `agent_id` state because the [`Capabilities`]
1838    /// struct itself doesn't know which families the MCP server
1839    /// actually advertised or which agent is asking.
1840    ///
1841    /// A5 bumps the default wire shape to v3. v2 stays supported
1842    /// indefinitely.
1843    #[must_use]
1844    pub fn to_v3(
1845        &self,
1846        summary: String,
1847        to_describe_to_user: String,
1848        tools: Vec<ToolEntry>,
1849        agent_permitted_families: Option<Vec<String>>,
1850        your_harness_supports_deferred_registration: Option<bool>,
1851    ) -> CapabilitiesV3 {
1852        CapabilitiesV3 {
1853            schema_version: "3".to_string(),
1854            summary,
1855            to_describe_to_user,
1856            tools,
1857            agent_permitted_families,
1858            your_harness_supports_deferred_registration,
1859            tier: self.tier.clone(),
1860            version: self.version.clone(),
1861            features: self.features.clone(),
1862            models: self.models.clone(),
1863            permissions: self.permissions.clone(),
1864            hooks: self.hooks.clone(),
1865            compaction: self.compaction.clone(),
1866            approval: self.approval.clone(),
1867            transcripts: self.transcripts.clone(),
1868            hnsw: self.hnsw.clone(),
1869            // v0.7 J1 — propagate the resolved KG backend tag verbatim.
1870            // None when no SAL adapter is wired (every pre-J2 build);
1871            // `Some("age" | "cte")` once the SAL handle is threaded.
1872            kg_backend: self.kg_backend.clone(),
1873            // L1-1 — propagate the memory-kind set verbatim.
1874            memory_kinds: self.memory_kinds.clone(),
1875            // L3-5 — four new substrate-honesty blocks. Built from
1876            // compile-time anchors (the per-block `::current()`
1877            // constructor) so the wire shape reflects the actual
1878            // implementation surface, not a static template.
1879            reflection: CapabilityReflection::current(),
1880            skills: CapabilitySkills::current(),
1881            forensic: CapabilityForensic::current(),
1882            governance: CapabilityGovernance::current(),
1883            // v0.7.0 WT-1-G — operator-facing atomisation surface.
1884            // Anchored at compile time against the WT-1-{A..F} ships
1885            // (engine, curator, hook, recall guard, forensic bundle,
1886            // MCP tool, CLI subcommand).
1887            atomisation: CapabilityAtomisation::current(),
1888            // v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1889            // vocabulary surface. Anchored at compile time against the
1890            // [`crate::models::MemoryKind`] enum + the recall-filter /
1891            // CLI / auto-classify wiring shipped under Form 6.
1892            memory_kind_vocab: CapabilityMemoryKindVocab::current(),
1893            // v0.7.0 Form 5 (issue #758) — confidence-calibration
1894            // surface. Anchored at compile time against the
1895            // `crate::confidence` module (derive, shadow, decay,
1896            // calibrate), the `ai-memory calibrate confidence` CLI
1897            // subcommand, and the `memory_calibrate_confidence` MCP
1898            // tool.
1899            confidence_calibration: CapabilityConfidenceCalibration::current(),
1900            // v0.7.0 #973 Item C — do-calculus / Ortega-de-Freitas
1901            // narrative surface. Helper does the source-tree honesty
1902            // check at the comment site; see the helper's docstring.
1903            provenance_substrate_layer: default_capability_provenance_substrate_layer(),
1904        }
1905    }
1906}
1907
1908/// v0.7.0 A3 — per-tool entry in the capabilities-v3 `tools` array.
1909///
1910/// `loaded` mirrors `Profile::loads(name)` — true when the active
1911/// profile would advertise this tool in `tools/list`.
1912///
1913/// `callable_now` is the AND of `loaded` with the
1914/// `[mcp.allowlist]` per-agent gate. When the allowlist is disabled
1915/// (no `[mcp.allowlist]` table or empty table), `callable_now ==
1916/// loaded`. When the allowlist is active and the requesting agent
1917/// has no entry granting the tool's family, `callable_now == false`
1918/// even though `loaded == true`.
1919///
1920/// LLMs that cache the v3 manifest can use this to skip a doomed
1921/// JSON-RPC call rather than discover -32601 the hard way.
1922#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1923pub struct ToolEntry {
1924    /// Fully-qualified MCP tool name (e.g., `memory_store`).
1925    pub name: String,
1926    /// Family the tool belongs to. Always one of the eight canonical
1927    /// family names (`core`, `lifecycle`, `graph`, etc.) or
1928    /// `"always_on"` for the `memory_capabilities` bootstrap which
1929    /// doesn't sit in any single family from a registration standpoint.
1930    pub family: String,
1931    /// Whether the active profile's family set includes this tool's
1932    /// family (i.e., it appears in `tools/list`).
1933    pub loaded: bool,
1934    /// `loaded && agent_can_call(agent_id, family)`. When the
1935    /// `[mcp.allowlist]` is disabled, `callable_now == loaded`.
1936    pub callable_now: bool,
1937    /// v0.7.0 issue #803 — 0-2 worked examples for the tool.
1938    /// `skip_serializing_if = "Vec::is_empty"` strips the field
1939    /// for any tool without curated examples.
1940    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1941    pub examples: Vec<ToolExample>,
1942}
1943
1944/// v0.7.0 issue #803 — single worked example for `tools[].examples`.
1945#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1946pub struct ToolExample {
1947    pub call: serde_json::Value,
1948    pub description: String,
1949}
1950
1951// ---------------------------------------------------------------------------
1952// Capabilities v3 — v0.7.0 attested-cortex schema (additive over v2)
1953// ---------------------------------------------------------------------------
1954
1955/// v0.7.0 capabilities schema (A1 increment). Additive over [`Capabilities`]
1956/// (v2): the top-level `summary` field carries a pre-computed,
1957/// plain-language description of the LLM's operational tool surface so
1958/// reasoning-class LLMs converge on accurate first-answer descriptions
1959/// without having to traverse `families[]` and count manually.
1960///
1961/// Wire selection: clients opt in via `accept="v3"` on the MCP
1962/// `memory_capabilities` call, or `Accept-Capabilities: v3` over HTTP
1963/// (HTTP wiring lands with A5). Default response remains v2 until A5
1964/// flips the default. v2 stays supported indefinitely.
1965///
1966/// Increment plan: A2 adds `to_describe_to_user`, A3 adds per-tool
1967/// `callable_now`, A4 adds `agent_permitted_families`. A5 bumps the
1968/// default wire shape and seals v3 as the recommended client target.
1969#[derive(Debug, Clone, Serialize, Deserialize)]
1970pub struct CapabilitiesV3 {
1971    /// Schema-version discriminator. Always `"3"` in v0.7.0.
1972    pub schema_version: String,
1973
1974    /// Pre-computed plain-language summary of operational access.
1975    /// Carries the loaded vs total tool counts under the active profile
1976    /// plus the three named recovery paths (`--profile`,
1977    /// `memory_load_family`, `memory_smart_load`). Computed at response
1978    /// time from the live profile state — never cached at build time
1979    /// because the count of advertised tools depends on the running
1980    /// server's `--profile` flag.
1981    pub summary: String,
1982
1983    /// v0.7.0 A2 — pre-computed end-user-facing sentence the LLM should
1984    /// repeat verbatim when an end-user asks "what tools do you have?".
1985    /// Distinct in tone from [`Self::summary`]: no MCP jargon, no
1986    /// `--profile` references, no JSON-RPC vocabulary. Reads as a
1987    /// normal sentence a human would say. See
1988    /// `docs/v0.7/canonical-phrasings.md` for the canonical
1989    /// substitution template + worked examples per profile.
1990    pub to_describe_to_user: String,
1991
1992    /// v0.7.0 A3 — per-tool array carrying name, family, `loaded`, and
1993    /// `callable_now`. `callable_now` combines profile-side
1994    /// loaded-state with the `[mcp.allowlist]` agent-can-call decision
1995    /// so an LLM that caches this manifest can skip a doomed JSON-RPC
1996    /// call rather than discovering -32601 the hard way. Order matches
1997    /// `tool_definitions()`'s registration walk so a sequential reader
1998    /// gets a stable presentation.
1999    pub tools: Vec<ToolEntry>,
2000
2001    /// v0.7.0 A4 — list of family names this agent is permitted to
2002    /// access via the `[mcp.allowlist]` gate. Present (with possibly
2003    /// an empty array) only when the allowlist is configured AND an
2004    /// `agent_id` was provided. Absent when the allowlist is disabled
2005    /// or no agent_id was provided — that absence is meaningful, not a
2006    /// drift, hence `Option<Vec<String>>` + `skip_serializing_if`.
2007    ///
2008    /// LLMs that keep a per-agent manifest cache can use this to
2009    /// short-circuit family-level decisions without iterating
2010    /// `tools[]` and counting unique families.
2011    #[serde(default, skip_serializing_if = "Option::is_none")]
2012    pub agent_permitted_families: Option<Vec<String>>,
2013
2014    /// v0.7.0 B4 — whether the active MCP harness exposes tools
2015    /// registered *after* the initial `tools/list` to the LLM. Computed
2016    /// at response time from the harness detected at the
2017    /// `initialize.clientInfo.name` handshake (see `crate::harness`).
2018    ///
2019    /// `Some(true)` only for Claude Code today (deferred registration
2020    /// via `ToolSearch`). `Some(false)` for every other named harness.
2021    /// `None` (omitted from the wire via `skip_serializing_if`) when
2022    /// no `clientInfo` was captured — typically HTTP callers, or an
2023    /// MCP client that issued `memory_capabilities` before
2024    /// `initialize` (malformed but defensively handled by absence).
2025    ///
2026    /// Track B's runtime loaders (B1 `memory_load_family`, B2
2027    /// `memory_smart_load`) key off this bit to shape their
2028    /// `to_invoke` text — on `false` harnesses they advise the LLM to
2029    /// ask the operator for a `--profile <family>` restart rather
2030    /// than expect the new tools to appear mid-session.
2031    #[serde(default, skip_serializing_if = "Option::is_none")]
2032    pub your_harness_supports_deferred_registration: Option<bool>,
2033
2034    pub tier: String,
2035    pub version: String,
2036    pub features: CapabilityFeatures,
2037    pub models: CapabilityModels,
2038    pub permissions: CapabilityPermissions,
2039    pub hooks: CapabilityHooks,
2040    pub compaction: CapabilityCompaction,
2041    pub approval: CapabilityApproval,
2042    pub transcripts: CapabilityTranscripts,
2043
2044    #[serde(default)]
2045    pub hnsw: CapabilityHnsw,
2046
2047    /// v0.7 J1 — knowledge-graph backend tag forwarded from the v2
2048    /// projection. `Some("age" | "cte")` once the SAL handle is
2049    /// threaded through `AppState`; `None` while no SAL adapter is
2050    /// wired. Skipped from the JSON wire when `None` so older clients
2051    /// that don't know the field round-trip cleanly.
2052    #[serde(default, skip_serializing_if = "Option::is_none")]
2053    pub kg_backend: Option<String>,
2054
2055    /// L1-1 (v0.7.0) — typed memory-kind set. Forwarded from the v2
2056    /// projection's `memory_kinds` field. Always
2057    /// `["observation", "reflection"]` for v0.7.0.
2058    ///
2059    /// **L3-5 honesty note.** The grand-slam spec called for a third
2060    /// `"goal"` kind here, but the [`crate::models::memory::MemoryKind`]
2061    /// enum in this binary only carries `Observation` and `Reflection`.
2062    /// Per the operator's "every reported field maps to real
2063    /// implementation" directive, the v3 surface reports exactly what
2064    /// the substrate enforces — the `goal` kind is deferred to the
2065    /// tracker (`a4f8d465`) for a v0.8.0 wave that lands the enum
2066    /// variant + migration + write-path coverage. Reporting it here
2067    /// today would be theatrical.
2068    #[serde(default = "default_memory_kinds")]
2069    pub memory_kinds: Vec<String>,
2070
2071    /// v0.7.0 L3-5 — recursive-learning capability surface. Every
2072    /// sub-field anchors a real implementation in this binary; see
2073    /// [`CapabilityReflection`] for the per-field audit anchors.
2074    #[serde(default = "default_capability_reflection")]
2075    pub reflection: CapabilityReflection,
2076
2077    /// v0.7.0 L3-5 — Agent-Skills capability surface. Lists the seven
2078    /// registered `memory_skill_*` MCP tools; the round-trip guarantee
2079    /// is pinned by `tests/skill_test.rs`. See [`CapabilitySkills`].
2080    #[serde(default = "default_capability_skills")]
2081    pub skills: CapabilitySkills,
2082
2083    /// v0.7.0 L3-5 — forensic-evidence CLI surface. Names the three
2084    /// driver verbs that this binary actually ships
2085    /// (`verify-reflection-chain`, `export-forensic-bundle`,
2086    /// `verify-forensic-bundle`). See [`CapabilityForensic`].
2087    #[serde(default = "default_capability_forensic")]
2088    pub forensic: CapabilityForensic,
2089
2090    /// v0.7.0 L3-5 — substrate-rules governance surface. Honestly
2091    /// labelled `"operator_signed"` because the L1-6 loader refuses
2092    /// to honour unsigned rules. See [`CapabilityGovernance`].
2093    #[serde(default = "default_capability_governance")]
2094    pub governance: CapabilityGovernance,
2095
2096    /// v0.7.0 WT-1-G — atomisation capability surface. Names the six
2097    /// operator-facing atomisation surfaces (`tool` / `cli` / `auto` /
2098    /// `recall_preference` / `forensic` / `curator`) plus the
2099    /// `derives_from` link relation that anchors atom → parent
2100    /// lineage. See [`CapabilityAtomisation`] for the per-field
2101    /// implementation anchor map.
2102    ///
2103    /// Additive over the L3-5 surface — pre-WT-1-G v3 payloads still
2104    /// deserialise cleanly (the `default_capability_atomisation`
2105    /// helper resolves to the current-implementation snapshot for any
2106    /// payload missing the field).
2107    #[serde(default = "default_capability_atomisation")]
2108    pub atomisation: CapabilityAtomisation,
2109
2110    /// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
2111    /// vocabulary capability surface. Names the recall-filter +
2112    /// auto-classify surfaces shipped under Form 6 and enumerates
2113    /// the substrate's full set of recognised `memory_kind` values.
2114    /// See [`CapabilityMemoryKindVocab`].
2115    ///
2116    /// Additive over the WT-1-G surface — pre-Form-6 v3 payloads
2117    /// deserialise cleanly via the
2118    /// `default_capability_memory_kind_vocab` helper.
2119    #[serde(default = "default_capability_memory_kind_vocab")]
2120    pub memory_kind_vocab: CapabilityMemoryKindVocab,
2121
2122    /// v0.7.0 Form 5 (issue #758) — confidence-calibration capability
2123    /// surface. Names the five operator-facing Form-5 substrates
2124    /// (`auto_derive` / `shadow_mode` / `freshness_decay` /
2125    /// `calibration_cli` / `calibration_tool`) plus the
2126    /// `signals_schema` wire-shape discriminator. See
2127    /// [`CapabilityConfidenceCalibration`] for the per-field anchor
2128    /// map.
2129    ///
2130    /// Additive over the WT-1-G surface — pre-Form-5 v3 payloads still
2131    /// deserialise cleanly because of the
2132    /// `default_capability_confidence_calibration` helper.
2133    #[serde(default = "default_capability_confidence_calibration")]
2134    pub confidence_calibration: CapabilityConfidenceCalibration,
2135
2136    /// v0.7.0 #973 Item C — narrative summary of the substrate's
2137    /// do-calculus posture.
2138    #[serde(default = "default_capability_provenance_substrate_layer")]
2139    pub provenance_substrate_layer: CapabilityProvenanceSubstrateLayer,
2140}
2141
2142/// v0.7.0 #973 Item C — substrate-layer provenance posture. Lets an
2143/// LLM agent self-describe ai-memory's do-calculus
2144/// intervention/observation distinction (Pearl 2009) per Ortega &
2145/// de Freitas (2026) framing. Honesty discipline: every
2146/// `enforcement_layers` entry must map to a shipped substrate
2147/// primitive in source.
2148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2149pub struct CapabilityProvenanceSubstrateLayer {
2150    #[serde(default)]
2151    pub posture: String,
2152    #[serde(default)]
2153    pub summary: String,
2154    #[serde(default)]
2155    pub enforcement_layers: Vec<String>,
2156    #[serde(default)]
2157    pub honest_limitations: Vec<String>,
2158    #[serde(default)]
2159    pub spec_references: SpecReferences,
2160}
2161
2162/// v0.7.0 #973 Item C — academic citations. Vendor-neutral.
2163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2164pub struct SpecReferences {
2165    #[serde(default)]
2166    pub do_calculus: String,
2167    #[serde(default)]
2168    pub interactional_agency: String,
2169}
2170
2171#[must_use]
2172pub fn default_capability_provenance_substrate_layer() -> CapabilityProvenanceSubstrateLayer {
2173    CapabilityProvenanceSubstrateLayer {
2174        posture: "do_calculus_aligned".to_string(),
2175        summary: "ai-memory implements the do-calculus intervention/observation \
2176                  distinction at the substrate layer via Form 4 fact-provenance, \
2177                  Form 6 MemoryKind vocabulary, Form 7 agent-EXTERNAL governance, \
2178                  the V-4 signed-events cross-row hash chain, and the seven Gap \
2179                  provenance framework; stops cross-session delusion amplification \
2180                  but not intra-session hallucination (consumer LLM responsibility)."
2181            .to_string(),
2182        enforcement_layers: vec![
2183            "form_4_fact_provenance".to_string(),
2184            "form_6_memory_kind".to_string(),
2185            "form_7_agent_external_governance".to_string(),
2186            "signed_events_v4_chain".to_string(),
2187            "seven_gap_framework".to_string(),
2188        ],
2189        honest_limitations: vec![
2190            "intra_session_hallucination_is_consumer_responsibility".to_string(),
2191            "federation_reliability_via_dlq_not_silent_drop".to_string(),
2192        ],
2193        spec_references: SpecReferences {
2194            do_calculus: "Pearl (2009)".to_string(),
2195            interactional_agency: "Ortega and de Freitas (2026)".to_string(),
2196        },
2197    }
2198}
2199
2200// ---------------------------------------------------------------------------
2201// TTL configuration
2202// ---------------------------------------------------------------------------
2203
2204/// Per-tier TTL overrides loaded from `[ttl]` section of config.toml.
2205#[allow(clippy::struct_field_names)]
2206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2207pub struct TtlConfig {
2208    /// Short-tier default TTL in seconds (default: 21600 = 6 hours)
2209    pub short_ttl_secs: Option<i64>,
2210    /// Mid-tier default TTL in seconds (default: 604800 = 7 days)
2211    pub mid_ttl_secs: Option<i64>,
2212    /// Long-tier TTL in seconds (default: none = never expires). Set >0 to add expiry.
2213    pub long_ttl_secs: Option<i64>,
2214    /// Short-tier TTL extension on access in seconds (default: 3600 = 1 hour)
2215    pub short_extend_secs: Option<i64>,
2216    /// Mid-tier TTL extension on access in seconds (default: 86400 = 1 day)
2217    pub mid_extend_secs: Option<i64>,
2218}
2219
2220/// Resolved TTL values after merging config overrides with compiled defaults.
2221#[derive(Debug, Clone)]
2222#[allow(clippy::struct_field_names)]
2223pub struct ResolvedTtl {
2224    pub short_ttl_secs: Option<i64>,
2225    pub mid_ttl_secs: Option<i64>,
2226    pub long_ttl_secs: Option<i64>,
2227    pub short_extend_secs: i64,
2228    pub mid_extend_secs: i64,
2229}
2230
2231impl Default for ResolvedTtl {
2232    fn default() -> Self {
2233        Self {
2234            short_ttl_secs: Tier::Short.default_ttl_secs(),
2235            mid_ttl_secs: Tier::Mid.default_ttl_secs(),
2236            long_ttl_secs: Tier::Long.default_ttl_secs(),
2237            short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
2238            mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
2239        }
2240    }
2241}
2242
2243/// Maximum configurable TTL: 10 years in seconds. Prevents integer overflow
2244/// when adding Duration to `Utc::now()`.
2245const MAX_TTL_SECS: i64 = 315_360_000;
2246
2247#[allow(dead_code)]
2248impl ResolvedTtl {
2249    /// Build from optional config overrides, falling back to compiled defaults.
2250    /// TTL values are clamped to `MAX_TTL_SECS` (10 years) to prevent overflow.
2251    /// Extension values are clamped to non-negative.
2252    pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
2253        let defaults = Self::default();
2254        let Some(c) = cfg else {
2255            return defaults;
2256        };
2257        let clamp_ttl = |v: i64| -> Option<i64> {
2258            if v <= 0 {
2259                None
2260            } else {
2261                Some(v.min(MAX_TTL_SECS))
2262            }
2263        };
2264        Self {
2265            short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
2266            mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
2267            long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
2268            short_extend_secs: c
2269                .short_extend_secs
2270                .unwrap_or(defaults.short_extend_secs)
2271                .max(0),
2272            mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
2273        }
2274    }
2275
2276    /// Get the default TTL for a given tier.
2277    pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
2278        match tier {
2279            Tier::Short => self.short_ttl_secs,
2280            Tier::Mid => self.mid_ttl_secs,
2281            Tier::Long => self.long_ttl_secs,
2282        }
2283    }
2284
2285    /// Get the TTL extension on access for a given tier.
2286    pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
2287        match tier {
2288            Tier::Short => Some(self.short_extend_secs),
2289            Tier::Mid => Some(self.mid_extend_secs),
2290            Tier::Long => None,
2291        }
2292    }
2293}
2294
2295// ---------------------------------------------------------------------------
2296// Transcript lifecycle (v0.7.0 I3) — per-namespace TTL + archive→prune
2297// ---------------------------------------------------------------------------
2298
2299/// Compiled-in default for the transcript TTL: 30 days. After this
2300/// many seconds elapse from `created_at` AND every memory that links
2301/// the transcript has expired (or been deleted), the I3 background
2302/// sweeper marks the transcript archived.
2303pub const DEFAULT_TRANSCRIPT_TTL_SECS: i64 = 2_592_000;
2304
2305/// Compiled-in default for the post-archive grace window: 7 days.
2306/// A transcript whose `archived_at` is older than this is hard-deleted
2307/// by the prune phase; the I2 join table is cleaned up via
2308/// `ON DELETE CASCADE`.
2309pub const DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS: i64 = crate::SECS_PER_WEEK;
2310
2311/// Maximum transcript TTL / grace clamp: 10 years in seconds. Mirrors
2312/// [`MAX_TTL_SECS`] above so the same overflow guard applies to the
2313/// transcript lifecycle math when the resolved value flows into a
2314/// `chrono::Duration`.
2315const MAX_TRANSCRIPT_LIFECYCLE_SECS: i64 = 315_360_000;
2316
2317/// `[transcripts]` block in `config.toml` — per-namespace TTL and
2318/// archive grace overrides for the I3 lifecycle sweeper.
2319///
2320/// ```toml
2321/// [transcripts]
2322/// default_ttl_secs   = 2592000   # 30 days; archive after this when memories all expired
2323/// archive_grace_secs = 604800    # 7 days; prune this long after archive
2324///
2325/// [transcripts.namespaces."team/audit"]
2326/// default_ttl_secs = 31536000    # 1 year — compliance retention override
2327///
2328/// [transcripts.namespaces."ephemeral/*"]
2329/// default_ttl_secs = 86400       # 1 day — short-lived scratchpad
2330/// ```
2331///
2332/// Resolution: the sweeper picks the longest-prefix matching namespace
2333/// override (with literal `"*"` patterns last), falls back to the
2334/// global `default_ttl_secs` / `archive_grace_secs` on this struct,
2335/// and finally to the compiled defaults above.
2336#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2337pub struct TranscriptsConfig {
2338    /// Global default seconds-since-creation before the sweeper
2339    /// considers a transcript archive-eligible. `None` → compiled
2340    /// default ([`DEFAULT_TRANSCRIPT_TTL_SECS`] = 30 days).
2341    pub default_ttl_secs: Option<i64>,
2342    /// Global default seconds an archived transcript lingers before
2343    /// the prune phase deletes it. `None` → compiled default
2344    /// ([`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`] = 7 days).
2345    pub archive_grace_secs: Option<i64>,
2346    /// Per-namespace overrides keyed by namespace pattern. Patterns
2347    /// are matched literally first; a trailing `/*` selects every
2348    /// child namespace under the prefix; the bare `"*"` is the
2349    /// catch-all and is consulted last.
2350    pub namespaces: Option<std::collections::HashMap<String, TranscriptNamespaceConfig>>,
2351    /// v0.7.0 I1 cap (#628 agent-3 follow-up): the maximum number of
2352    /// bytes a single transcript may decompress to before
2353    /// `transcripts::fetch` rejects it as a decompression bomb. `None`
2354    /// → compiled default ([`crate::transcripts::MAX_DECOMPRESSED_BYTES`]
2355    /// = 16 MiB). Operators with legitimately larger transcripts
2356    /// raise the cap explicitly; the cap is per-call, so concurrent
2357    /// fetches consume up to N × this value of transient memory.
2358    pub max_decompressed_bytes: Option<usize>,
2359}
2360
2361/// Per-namespace overrides nested under
2362/// `[transcripts.namespaces."<pattern>"]`. Each field independently
2363/// overrides the [`TranscriptsConfig`] global default; an unset field
2364/// inherits.
2365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2366pub struct TranscriptNamespaceConfig {
2367    /// Namespace-specific TTL override.
2368    pub default_ttl_secs: Option<i64>,
2369    /// Namespace-specific archive-grace override.
2370    pub archive_grace_secs: Option<i64>,
2371    /// v0.7 I5 — opt in the namespace to the reference R5 pre_store
2372    /// transcript-extractor hook (`tools/transcript-extractor/`).
2373    /// Default `None` → disabled, matching the "default off" lesson
2374    /// from G3-G11. Operators that wire the extractor binary into
2375    /// their `hooks.toml` set this flag per namespace to gate the
2376    /// derived-memory expansion. `Some(false)` is identical to
2377    /// `None` and exists so an explicit "no, don't extract here"
2378    /// can be expressed alongside a wildcard `Some(true)`.
2379    #[serde(skip_serializing_if = "Option::is_none")]
2380    pub auto_extract: Option<bool>,
2381}
2382
2383/// Resolved transcript-lifecycle parameters for a single namespace.
2384/// Produced by [`TranscriptsConfig::resolve`] and consumed by the I3
2385/// sweeper to drive the archive + prune SQL.
2386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2387pub struct ResolvedTranscriptLifecycle {
2388    /// Seconds-since-creation before archive eligibility. Always
2389    /// positive and `<= MAX_TRANSCRIPT_LIFECYCLE_SECS`.
2390    pub default_ttl_secs: i64,
2391    /// Seconds an archived row lingers before prune. Always
2392    /// positive and `<= MAX_TRANSCRIPT_LIFECYCLE_SECS`.
2393    pub archive_grace_secs: i64,
2394}
2395
2396impl Default for ResolvedTranscriptLifecycle {
2397    fn default() -> Self {
2398        Self {
2399            default_ttl_secs: DEFAULT_TRANSCRIPT_TTL_SECS,
2400            archive_grace_secs: DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS,
2401        }
2402    }
2403}
2404
2405impl TranscriptsConfig {
2406    /// Resolve the lifecycle parameters for `namespace`.
2407    ///
2408    /// Precedence:
2409    /// 1. Exact match in `namespaces` (e.g. `"team/audit"`).
2410    /// 2. Longest matching prefix pattern ending in `/*` (e.g.
2411    ///    `"team/*"` matches `"team/eng"` and `"team/eng/inner"`).
2412    /// 3. Bare `"*"` wildcard.
2413    /// 4. The struct-level `default_ttl_secs` / `archive_grace_secs`.
2414    /// 5. The compiled defaults
2415    ///    ([`DEFAULT_TRANSCRIPT_TTL_SECS`] / [`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`]).
2416    ///
2417    /// Each field is resolved independently — a per-namespace override
2418    /// that only sets `default_ttl_secs` inherits the global
2419    /// `archive_grace_secs`. Non-positive values fall through to the
2420    /// next layer; positive values are clamped to
2421    /// `MAX_TRANSCRIPT_LIFECYCLE_SECS` so the resolved `Duration`
2422    /// addition can never overflow `chrono`.
2423    #[must_use]
2424    pub fn resolve(&self, namespace: &str) -> ResolvedTranscriptLifecycle {
2425        let ns_table = self.namespaces.as_ref();
2426
2427        // Walk the namespace overrides in precedence order, returning
2428        // the first that names the field. `None` means "fall through".
2429        let pick_ns = |field: fn(&TranscriptNamespaceConfig) -> Option<i64>| -> Option<i64> {
2430            let table = ns_table?;
2431            // 1. Exact literal match.
2432            if let Some(ns) = table.get(namespace) {
2433                if let Some(v) = field(ns) {
2434                    return Some(v);
2435                }
2436            }
2437            // 2. Longest-prefix `prefix/*` match.
2438            let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
2439                .iter()
2440                .filter_map(|(k, v)| {
2441                    let prefix = k.strip_suffix("/*")?;
2442                    if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
2443                        Some((prefix, v))
2444                    } else {
2445                        None
2446                    }
2447                })
2448                .collect();
2449            prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
2450            for (_, ns) in &prefix_hits {
2451                if let Some(v) = field(ns) {
2452                    return Some(v);
2453                }
2454            }
2455            // 3. Bare wildcard.
2456            if let Some(ns) = table.get("*") {
2457                if let Some(v) = field(ns) {
2458                    return Some(v);
2459                }
2460            }
2461            None
2462        };
2463
2464        let clamp = |v: i64, fallback: i64| -> i64 {
2465            if v <= 0 {
2466                fallback
2467            } else {
2468                v.min(MAX_TRANSCRIPT_LIFECYCLE_SECS)
2469            }
2470        };
2471
2472        let ttl = pick_ns(|n| n.default_ttl_secs)
2473            .or(self.default_ttl_secs)
2474            .map_or(DEFAULT_TRANSCRIPT_TTL_SECS, |v| {
2475                clamp(v, DEFAULT_TRANSCRIPT_TTL_SECS)
2476            });
2477        let grace = pick_ns(|n| n.archive_grace_secs)
2478            .or(self.archive_grace_secs)
2479            .map_or(DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS, |v| {
2480                clamp(v, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS)
2481            });
2482
2483        ResolvedTranscriptLifecycle {
2484            default_ttl_secs: ttl,
2485            archive_grace_secs: grace,
2486        }
2487    }
2488
2489    /// v0.7 I5 — resolve the `auto_extract` opt-in for `namespace`.
2490    ///
2491    /// Same precedence walk as [`Self::resolve`] but folds the
2492    /// boolean field of [`TranscriptNamespaceConfig::auto_extract`]:
2493    ///
2494    /// 1. Exact match.
2495    /// 2. Longest-prefix `prefix/*` match.
2496    /// 3. Bare wildcard `"*"`.
2497    /// 4. `false` (default off — matches the "every reference hook
2498    ///    ships off-by-default" lesson from G10/G11).
2499    ///
2500    /// The R5 reference extractor (`tools/transcript-extractor/`)
2501    /// reads this flag at the namespace gate before doing any LLM
2502    /// work, so a namespace that hasn't opted in pays the cost of
2503    /// one HashMap lookup per `pre_store` fire and nothing more.
2504    #[must_use]
2505    pub fn auto_extract_for(&self, namespace: &str) -> bool {
2506        let Some(table) = self.namespaces.as_ref() else {
2507            return false;
2508        };
2509        // 1. Exact literal match.
2510        if let Some(ns) = table.get(namespace) {
2511            if let Some(v) = ns.auto_extract {
2512                return v;
2513            }
2514        }
2515        // 2. Longest-prefix `prefix/*` match.
2516        let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
2517            .iter()
2518            .filter_map(|(k, v)| {
2519                let prefix = k.strip_suffix("/*")?;
2520                if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
2521                    Some((prefix, v))
2522                } else {
2523                    None
2524                }
2525            })
2526            .collect();
2527        prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
2528        for (_, ns) in &prefix_hits {
2529            if let Some(v) = ns.auto_extract {
2530                return v;
2531            }
2532        }
2533        // 3. Bare wildcard.
2534        if let Some(ns) = table.get("*") {
2535            if let Some(v) = ns.auto_extract {
2536                return v;
2537            }
2538        }
2539        // 4. Default off.
2540        false
2541    }
2542}
2543
2544// ---------------------------------------------------------------------------
2545// Recall scoring (time-decay half-life) — v0.6.0.0
2546// ---------------------------------------------------------------------------
2547
2548/// Per-tier half-life (days) overrides loaded from `[scoring]` section of
2549/// `config.toml`.
2550///
2551/// The half-life is the number of days it takes for a memory's recall score
2552/// to drop to 50% of its undecayed value. Shorter half-lives prioritize fresh
2553/// memories; longer half-lives give older memories more weight. Defaults are
2554/// chosen so each tier's decay curve matches its retention expectations:
2555/// `short` memories decay quickly (7 d), `mid` moderately (30 d), `long`
2556/// slowly (365 d).
2557///
2558/// Setting `legacy_scoring = true` disables the decay multiplier entirely,
2559/// restoring the pre-v0.6.0.0 blended-score behavior for A/B comparison or
2560/// if a recall-quality regression is reported.
2561#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2562pub struct RecallScoringConfig {
2563    /// Half-life for `short`-tier memories, in days (default 7).
2564    pub half_life_days_short: Option<f64>,
2565    /// Half-life for `mid`-tier memories, in days (default 30).
2566    pub half_life_days_mid: Option<f64>,
2567    /// Half-life for `long`-tier memories, in days (default 365).
2568    pub half_life_days_long: Option<f64>,
2569    /// When true, skip the decay multiplier entirely. Default false.
2570    #[serde(default)]
2571    pub legacy_scoring: bool,
2572}
2573
2574/// Resolved scoring values after merging config overrides with compiled
2575/// defaults. Half-lives are clamped to the range `[0.1, 36_500.0]` days
2576/// (≈100 years) to keep the decay math well-behaved.
2577#[derive(Debug, Clone, Copy)]
2578pub struct ResolvedScoring {
2579    pub half_life_days_short: f64,
2580    pub half_life_days_mid: f64,
2581    pub half_life_days_long: f64,
2582    pub legacy_scoring: bool,
2583}
2584
2585impl Default for ResolvedScoring {
2586    fn default() -> Self {
2587        Self {
2588            half_life_days_short: 7.0,
2589            half_life_days_mid: 30.0,
2590            half_life_days_long: 365.0,
2591            legacy_scoring: false,
2592        }
2593    }
2594}
2595
2596impl ResolvedScoring {
2597    const MIN_HALF_LIFE: f64 = 0.1;
2598    const MAX_HALF_LIFE: f64 = 36_500.0;
2599
2600    /// Build from optional config overrides, falling back to compiled
2601    /// defaults. Out-of-range values are silently clamped.
2602    pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
2603        let defaults = Self::default();
2604        let Some(c) = cfg else {
2605            return defaults;
2606        };
2607        let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
2608        Self {
2609            half_life_days_short: c
2610                .half_life_days_short
2611                .map_or(defaults.half_life_days_short, clamp),
2612            half_life_days_mid: c
2613                .half_life_days_mid
2614                .map_or(defaults.half_life_days_mid, clamp),
2615            half_life_days_long: c
2616                .half_life_days_long
2617                .map_or(defaults.half_life_days_long, clamp),
2618            legacy_scoring: c.legacy_scoring,
2619        }
2620    }
2621
2622    /// Half-life in days for a given tier.
2623    pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
2624        match tier {
2625            Tier::Short => self.half_life_days_short,
2626            Tier::Mid => self.half_life_days_mid,
2627            Tier::Long => self.half_life_days_long,
2628        }
2629    }
2630
2631    /// Compute the decay multiplier `exp(-ln(2) * age_days / half_life)`
2632    /// for a memory of the given tier and age. Returns `1.0` when
2633    /// `legacy_scoring` is true (no decay) or when `age_days` is non-positive
2634    /// (future timestamps, clock skew, or new memories).
2635    #[must_use]
2636    pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
2637        if self.legacy_scoring || age_days <= 0.0 {
2638            return 1.0;
2639        }
2640        let half_life = self.half_life_for_tier(tier);
2641        (-std::f64::consts::LN_2 * age_days / half_life).exp()
2642    }
2643}
2644
2645// ---------------------------------------------------------------------------
2646// Persistent config file (~/.config/ai-memory/config.toml)
2647// ---------------------------------------------------------------------------
2648
2649const CONFIG_DIR: &str = ".config/ai-memory";
2650const CONFIG_FILE: &str = "config.toml";
2651
2652/// Persistent configuration loaded from `~/.config/ai-memory/config.toml`.
2653///
2654/// All fields are optional — CLI flags override file values, which override
2655/// compiled defaults.
2656#[derive(Clone, Default, Serialize, Deserialize)]
2657pub struct AppConfig {
2658    /// Feature tier: keyword, semantic, smart, autonomous
2659    pub tier: Option<String>,
2660    /// Path to the `SQLite` database file
2661    pub db: Option<String>,
2662    /// Ollama base URL for LLM generation (default: <http://localhost:11434>)
2663    ///
2664    /// DOC-6 (FX-C4-batch2, 2026-05-26): legacy flat field, slated
2665    /// for removal in v0.8.0. Use the sectioned `[llm].base_url` /
2666    /// `[embeddings].url` shape from #1146 instead. Run
2667    /// `ai-memory config migrate` to rewrite legacy configs.
2668    #[deprecated(
2669        since = "0.7.0",
2670        note = "use the sectioned `[llm].base_url` / `[embeddings].url` (#1146); slated for removal in v0.8.0"
2671    )]
2672    pub ollama_url: Option<String>,
2673    /// Separate URL for embedding model (defaults to `ollama_url` if unset)
2674    ///
2675    /// DOC-6: legacy; use `[embeddings].url`.
2676    #[deprecated(
2677        since = "0.7.0",
2678        note = "use `[embeddings].url` (#1146); slated for removal in v0.8.0"
2679    )]
2680    pub embed_url: Option<String>,
2681    /// Embedding model override: `mini_lm_l6_v2` or `nomic_embed_v15`
2682    ///
2683    /// DOC-6: legacy; use `[embeddings].model`.
2684    #[deprecated(
2685        since = "0.7.0",
2686        note = "use `[embeddings].model` (#1146); slated for removal in v0.8.0"
2687    )]
2688    pub embedding_model: Option<String>,
2689    /// LLM model override (Ollama tag, e.g. "gemma4:e2b")
2690    ///
2691    /// DOC-6: legacy; use `[llm].model`.
2692    #[deprecated(
2693        since = "0.7.0",
2694        note = "use `[llm].model` (#1146); slated for removal in v0.8.0"
2695    )]
2696    pub llm_model: Option<String>,
2697    /// Dedicated model for auto_tag (and other short-structured LLM calls).
2698    /// Defaults to `gemma3:4b` (fast, deterministic, ~0.7s p50 vs 15s for
2699    /// thinking-mode Gemma 4). Falls back to `llm_model` if unset.
2700    /// See L15 patch (2026-05-11) for rationale.
2701    ///
2702    /// DOC-6: legacy; use `[llm.auto_tag].model`.
2703    #[deprecated(
2704        since = "0.7.0",
2705        note = "use `[llm.auto_tag].model` (#1146); slated for removal in v0.8.0"
2706    )]
2707    pub auto_tag_model: Option<String>,
2708    /// Enable cross-encoder reranking (true/false)
2709    ///
2710    /// DOC-6: legacy; use `[reranker].enabled`.
2711    #[deprecated(
2712        since = "0.7.0",
2713        note = "use `[reranker].enabled` (#1146); slated for removal in v0.8.0"
2714    )]
2715    pub cross_encoder: Option<bool>,
2716    /// Default namespace for new memories
2717    ///
2718    /// DOC-6: legacy; use `[storage].default_namespace`.
2719    #[deprecated(
2720        since = "0.7.0",
2721        note = "use `[storage].default_namespace` (#1146); slated for removal in v0.8.0"
2722    )]
2723    pub default_namespace: Option<String>,
2724    /// Maximum memory budget in MB (used for auto tier selection)
2725    ///
2726    /// DOC-6: legacy; the auto-tier path now resolves via the
2727    /// sectioned `[storage]` block.
2728    #[deprecated(
2729        since = "0.7.0",
2730        note = "auto-tier resolution now resolves via the sectioned [storage] block (#1146); slated for removal in v0.8.0"
2731    )]
2732    pub max_memory_mb: Option<usize>,
2733    /// Per-tier TTL overrides
2734    pub ttl: Option<TtlConfig>,
2735    /// Archive memories before GC deletion (default: true)
2736    ///
2737    /// DOC-6: legacy; use `[storage].archive_on_gc`.
2738    #[deprecated(
2739        since = "0.7.0",
2740        note = "use `[storage].archive_on_gc` (#1146); slated for removal in v0.8.0"
2741    )]
2742    pub archive_on_gc: Option<bool>,
2743    /// Optional API key for HTTP API authentication.
2744    ///
2745    /// #1262 — `skip_serializing` prevents the secret from being
2746    /// echoed back through any `serde_json::to_string(&AppConfig)`
2747    /// path (capabilities overlays, debug dumps, audit traces).
2748    /// #1454 — the manual `Debug` impl on `AppConfig` (just below the
2749    /// struct) renders this field as `<redacted>`, so a `{:?}` of the
2750    /// config never leaks the secret either (`skip_serializing` only
2751    /// guards the serde JSON path, not `Debug`).
2752    /// #1258 — [`AppConfig::zeroize_secrets`] (a free helper method,
2753    /// NOT a blanket `Drop` impl) zeroizes this buffer; callers invoke
2754    /// it immediately before scope-exit. A blanket `Drop` is
2755    /// deliberately avoided so the `..AppConfig::default()`
2756    /// struct-update spread used across ~20 test sites still compiles.
2757    #[serde(default, skip_serializing)]
2758    pub api_key: Option<String>,
2759    /// Maximum archive age in days for automatic purge during GC (default: disabled)
2760    ///
2761    /// DOC-6: legacy; the archive purge knob resolves via the
2762    /// sectioned `[storage]` block at v0.7.x.
2763    #[deprecated(
2764        since = "0.7.0",
2765        note = "archive purge resolution moves under the sectioned [storage] block (#1146); slated for removal in v0.8.0"
2766    )]
2767    pub archive_max_days: Option<i64>,
2768    /// Identity-resolution overrides (Task 1.2 follow-up #198).
2769    pub identity: Option<IdentityConfig>,
2770    /// Recall scoring — per-tier half-life for time-decay, and `legacy_scoring`
2771    /// kill switch (v0.6.0.0).
2772    pub scoring: Option<RecallScoringConfig>,
2773    /// v0.6.0.0: when true, fire LLM autonomy hooks (`auto_tag` +
2774    /// `detect_contradiction`) synchronously on every successful
2775    /// `memory_store`. Off by default — the hook blocks store latency
2776    /// behind an Ollama round-trip. `AI_MEMORY_AUTONOMOUS_HOOKS=1`
2777    /// env var overrides the config file.
2778    pub autonomous_hooks: Option<bool>,
2779    /// v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
2780    /// Default-OFF for privacy; opt-in turns on the rolling file
2781    /// appender that captures every `tracing::*` call site to disk.
2782    pub logging: Option<LoggingConfig>,
2783    /// v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF
2784    /// for privacy; opt-in emits a hash-chained, tamper-evident JSON
2785    /// log of every memory mutation suitable for SIEM ingestion and
2786    /// SOC2 / HIPAA / GDPR / FedRAMP compliance evidence.
2787    pub audit: Option<AuditConfig>,
2788    /// v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy
2789    /// kill-switch. Default-ON (existing users see no behavior change);
2790    /// `[boot] enabled = false` silences boot entirely (empty stdout +
2791    /// empty stderr, exit 0) for privacy-sensitive hosts where memory
2792    /// titles must not enter CI logs. `[boot] redact_titles = true`
2793    /// keeps the manifest header but replaces row titles with
2794    /// `<redacted>` for compliance contexts that need the audit-trail
2795    /// signal of "boot ran with N memories" without exposing subjects.
2796    pub boot: Option<BootConfig>,
2797    /// v0.6.4 — MCP server tunables. Today this only carries `profile`
2798    /// (the named tool surface). Future v0.6.4 phases add the
2799    /// `[mcp.allowlist]` per-agent capability table (Track D —
2800    /// v0.6.4-008).
2801    pub mcp: Option<McpConfig>,
2802    /// v0.7.0 K3 — `[permissions]` block. Drives the gate's enforcement
2803    /// posture (`enforce` / `advisory` / `off`). When unset, the
2804    /// compiled default in [`PermissionsConfig::default`] applies
2805    /// (`advisory` — preserves the v0.6.x honest-disclosure posture
2806    /// where governance metadata was recorded but not blocked at the
2807    /// gate). New installs that want the strict gate set
2808    /// `[permissions] mode = "enforce"` explicitly.
2809    pub permissions: Option<PermissionsConfig>,
2810    /// v0.7.0 I3 — `[transcripts]` block. Per-namespace TTL and
2811    /// archive-grace overrides for the transcript lifecycle sweeper.
2812    /// Unset → compiled defaults apply globally
2813    /// ([`DEFAULT_TRANSCRIPT_TTL_SECS`] / [`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`]).
2814    pub transcripts: Option<TranscriptsConfig>,
2815    /// v0.7.0 K7 — `[hooks]` block. Currently carries the
2816    /// `[hooks.subscription] hmac_secret` server-wide override that
2817    /// signs every outgoing webhook payload regardless of whether the
2818    /// individual subscription supplied a per-subscription secret.
2819    /// When unset, only per-subscription secrets are used (legacy
2820    /// pre-K7 behaviour).
2821    pub hooks: Option<HooksConfig>,
2822    /// v0.7.0 H11 (#628 blocker) — `[subscriptions]` block. Carries
2823    /// the `allow_loopback_webhooks` opt-in that re-enables loopback
2824    /// webhook URLs (`127.0.0.1`, `localhost`, `[::1]`). Default-OFF
2825    /// closes an authenticated SSRF gadget against local services
2826    /// (Postgres on 5432, the hooks daemon, etc.). Operators who need
2827    /// loopback for testing must set this explicitly.
2828    pub subscriptions: Option<SubscriptionsConfig>,
2829    /// v0.7.0 H5 (round-2) — `[verify]` block. Today exposes one
2830    /// knob: `require_nonce` (default `false`). When `true`, every
2831    /// `POST /api/v1/links/verify` request MUST include a
2832    /// `verification_nonce` (UUID v4 expected); missing or replayed
2833    /// nonces are rejected with 409 Conflict. Default-OFF preserves
2834    /// the v0.6.x verify-anytime semantics for unmigrated clients.
2835    pub verify: Option<VerifyConfig>,
2836    /// v0.7.0 M4 — connection-level `statement_timeout` (in seconds)
2837    /// applied via an `after_connect` hook to every postgres
2838    /// connection in the pool. Bounds runaway queries — a pathological
2839    /// `pg_sleep(60)` or an unbounded scan can otherwise wedge a
2840    /// connection forever. Defaults to 30s when unset; set to 0 to
2841    /// disable the limit (matches the postgres `SET` semantics).
2842    /// Operators only need to touch this when the workload requires
2843    /// long-running maintenance queries from the daemon itself.
2844    pub postgres_statement_timeout_secs: Option<u64>,
2845    /// v0.7.0 (a) — connection-pool ceiling (sqlx `max_connections`)
2846    /// for the postgres backend. `None` selects the compiled
2847    /// `DEFAULT_MAX_CONNECTIONS`. Operators tune this per module/daemon
2848    /// without a recompile via `AI_MEMORY_PG_POOL_MAX`. Resolved by
2849    /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2850    /// to the default.
2851    pub postgres_pool_max_connections: Option<u32>,
2852    /// v0.7.0 (a) — connection-pool floor of always-open warm
2853    /// connections (sqlx `min_connections`). `None` selects the
2854    /// compiled `DEFAULT_MIN_CONNECTIONS`. Operator knob:
2855    /// `AI_MEMORY_PG_POOL_MIN`. Resolved by
2856    /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2857    /// to the default.
2858    pub postgres_pool_min_connections: Option<u32>,
2859    /// v0.7.0 (a) — how long a pool `acquire()` waits for a free
2860    /// connection before erroring (sqlx `acquire_timeout`), in whole
2861    /// seconds. `None` selects the compiled default derived from
2862    /// `DEFAULT_ACQUIRE_TIMEOUT`. Operator knob:
2863    /// `AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS`. Resolved by
2864    /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2865    /// to the default.
2866    pub postgres_acquire_timeout_secs: Option<u64>,
2867    /// v0.7.0 H7 (round-2) — per-HTTP-request wall-clock timeout in
2868    /// seconds. Applied as a middleware to every axum route in
2869    /// [`crate::build_router`] so a slow-POST (slowloris-style)
2870    /// attacker cannot keep a handler scope alive indefinitely.
2871    /// `None` selects the compiled default of 60 seconds; operators
2872    /// who need a different ceiling set
2873    /// `request_timeout_secs = <secs>` in `config.toml`.
2874    pub request_timeout_secs: Option<u64>,
2875    /// v0.7.0 H8 (round-2) — per-LLM-call wall-clock timeout in
2876    /// seconds. Wraps every `spawn_blocking` invocation of an Ollama
2877    /// call (`auto_tag`, `expand_query`, `summarize_memories`, ...)
2878    /// in `tokio::time::timeout`. `None` selects the compiled
2879    /// default of 30 seconds; on timeout the call falls back to the
2880    /// LLM-absent path (already exercised by L5/L7).
2881    pub llm_call_timeout_secs: Option<u64>,
2882    /// v0.7.0 (issue #318) — when set, the MCP stdio server forwards
2883    /// every write tool (`memory_store`, `memory_link`, `memory_delete`)
2884    /// to this HTTP endpoint (typically the local `ai-memory serve`
2885    /// daemon at `http://localhost:9077`) instead of writing to SQLite
2886    /// directly. The HTTP daemon then runs the existing
2887    /// `broadcast_store_quorum` / `broadcast_link_quorum` / etc. fanout,
2888    /// closing the gap surfaced by a2a-gate v0.6.0 r6 where MCP-stdio
2889    /// writes replicated locally but never reached the federation mesh.
2890    ///
2891    /// Unset (the default) keeps the legacy direct-SQLite path so
2892    /// single-node MCP deployments without a federation daemon behave
2893    /// exactly as before. The forwarder uses `reqwest::blocking` and
2894    /// surfaces HTTP errors as MCP error strings; on transport failure
2895    /// the response carries the underlying error so operators can
2896    /// distinguish "fanout daemon not running" from "quorum not met".
2897    pub mcp_federation_forward_url: Option<String>,
2898    /// v0.7.0 (issue #518) — `[agents.defaults]` block. Carries the
2899    /// `recall_scope` defaults spliced into `memory_recall` /
2900    /// `GET /api/v1/recall` / `ai-memory recall` requests that pass
2901    /// `session_default=true` (or `--session-default` on the CLI) and
2902    /// omit one or more filter fields. Closes the OpenClaw v0.6.3.1
2903    /// "what were you working on?" recovery gap — agents picking up a
2904    /// new session no longer need to remember to splice the canonical
2905    /// namespace + recency filters on every cross-session recall.
2906    ///
2907    /// `None` (the default) preserves single-tenant deployments and
2908    /// existing recall semantics exactly as-is. The splice happens in
2909    /// the handler before the storage call; explicit args always win
2910    /// over the defaults.
2911    pub agents: Option<AgentsConfig>,
2912    /// v0.7.0 SEC-2 (Cluster D, issue #767) — `[governance]` block.
2913    /// Today exposes one knob: `require_operator_pubkey` (default
2914    /// `false`). When `true`, daemon `serve` startup REFUSES to boot
2915    /// if the `governance_rules` table contains any `enabled = 1`
2916    /// rows AND no operator pubkey is resolved (env var or
2917    /// `~/.config/ai-memory/operator.key.pub`). Closes the
2918    /// fail-OPEN gap where a SQL-write gadget could install
2919    /// `enabled = 1` rules that the pre-L1-6 loader would honour
2920    /// without signature check. Default `false` preserves the
2921    /// pre-cluster-D contract for the install-script deploy where
2922    /// no operator pubkey is yet on disk.
2923    pub governance: Option<GovernanceConfig>,
2924    /// v0.7.0 Cluster G (#767) — `[confidence]` block. Carries the
2925    /// retention window for `confidence_shadow_observations` consumed
2926    /// by the periodic GC sweep (`shadow_retention_days`, default 30).
2927    /// Unset → the compiled default applies. Closes PERF-4: the v0.7.0
2928    /// Form 5 closeout (#758) shipped the shadow-mode table but did
2929    /// NOT ship retention, so a long-running shadow-enabled deployment
2930    /// would see unbounded growth.
2931    pub confidence: Option<ConfidenceConfig>,
2932    /// v0.7.0 SHIP cluster (#946 / #957 / #960 / #961, 2026-05-20) —
2933    /// `[admin]` top-level block. Carries the operator-configured
2934    /// allowlist of `agent_ids` whose authenticated HTTP requests
2935    /// are treated as admin-class callers (full cross-tenant
2936    /// visibility for endpoints that must observe corpus-scale
2937    /// metadata: `GET /api/v1/export`, `GET /api/v1/agents`,
2938    /// `GET /api/v1/stats`, the `POST /api/v1/quota/status` list
2939    /// path). `None` (the default) closes those endpoints to all
2940    /// non-admin callers — the safe-by-default posture per CLAUDE.md
2941    /// `pm-v3`. See [`AdminConfig`] for the full role-gate semantics.
2942    pub admin: Option<AdminConfig>,
2943
2944    // ------------------------------------------------------------------
2945    // v0.7.x enterprise configuration sections (issue #1146).
2946    //
2947    // These four sectioned blocks (`[llm]` / `[embeddings]` /
2948    // `[reranker]` / `[storage]`) consolidate the previously-flat
2949    // LLM / embedder / reranker / storage knobs into named tables with
2950    // a uniform canonical resolver. Legacy flat fields above
2951    // (`llm_model`, `ollama_url`, `embed_url`, `embedding_model`,
2952    // `cross_encoder`, `default_namespace`, `archive_on_gc`,
2953    // `archive_max_days`, `max_memory_mb`) continue to parse and feed
2954    // the resolver's legacy arm with a one-shot deprecation WARN until
2955    // v0.8.0 removes them.
2956    //
2957    // The `schema_version` field carries the explicit shape version.
2958    // Absent / `1` selects the legacy parse path; `>= 2` selects the
2959    // sectioned parse path and warns when legacy fields are also
2960    // present (so an operator who hand-edited the file knows the
2961    // legacy fields are dead weight).
2962    // ------------------------------------------------------------------
2963    /// v0.7.x (#1146) — explicit configuration schema version. `None`
2964    /// or `1` selects the v0.6.x flat-field parse path; `2` selects
2965    /// the sectioned parse path (`[llm]`, `[embeddings]`, `[reranker]`,
2966    /// `[storage]`) and emits a WARN if any legacy flat field is also
2967    /// present. Future bumps (`3`, `4`, …) introduce additional schema
2968    /// transitions and are gated through `ai-memory config migrate`.
2969    pub schema_version: Option<u32>,
2970
2971    /// v0.7.x (#1146) — `[llm]` sectioned LLM configuration. Carries
2972    /// the canonical backend / model / base_url / api_key references
2973    /// consumed by every LLM-init surface (MCP stdio, HTTP daemon,
2974    /// `ai-memory atomise`, `ai-memory curator`, embed-client
2975    /// disambiguator, the boot banner). Resolved via
2976    /// [`AppConfig::resolve_llm`]; the resolver applies the uniform
2977    /// precedence ladder (CLI flag > `AI_MEMORY_LLM_*` env > `[llm]`
2978    /// section > legacy flat fields > compiled default).
2979    ///
2980    /// Includes an optional `[llm.auto_tag]` sub-table for the fast
2981    /// structured-output sibling that handles `auto_tag`, query
2982    /// expansion, and contradiction detection — see [`LlmSection`].
2983    pub llm: Option<LlmSection>,
2984
2985    /// v0.7.x (#1146) — `[embeddings]` sectioned embedding-model
2986    /// configuration. Consumed by the embedder bootstrap in
2987    /// `daemon_runtime` and the MCP embed-client fallback path.
2988    /// Resolved via [`AppConfig::resolve_embeddings`].
2989    pub embeddings: Option<EmbeddingsSection>,
2990
2991    /// v0.7.x (#1146) — `[reranker]` sectioned cross-encoder
2992    /// configuration. Folds the legacy `cross_encoder = bool` knob
2993    /// into a `{ enabled, model }` table with explicit model
2994    /// selection. Resolved via [`AppConfig::resolve_reranker`].
2995    pub reranker: Option<RerankerSection>,
2996
2997    /// #1671/n15 (v0.7.1) — `[curator]` per-namespace curator config.
2998    /// Carries the per-namespace `reflection_pass.enabled` gate that
2999    /// `curator --reflect --all-namespaces` consults (#1671 — without it
3000    /// `--all-namespaces` reflected nothing) and the per-namespace
3001    /// `confidence_decay_half_life_days` override the confidence-decay
3002    /// sweep consults (n15). Resolved via
3003    /// [`AppConfig::reflection_namespace_enabled`] and
3004    /// [`AppConfig::confidence_decay_half_life_for`].
3005    pub curator: Option<CuratorSection>,
3006
3007    /// v0.7.x (#1146) — `[storage]` sectioned storage configuration.
3008    /// Carries `default_namespace`, `archive_on_gc`, `archive_max_days`,
3009    /// `max_memory_mb` (folded from the previously-flat top-level
3010    /// fields). The `db` path stays top-level per the I4 carve-out in
3011    /// #1146 (path expansion semantics pinned by #507).
3012    pub storage: Option<StorageSection>,
3013
3014    /// v0.7.x — `[limits]` sectioned operator-tunable capacity limits.
3015    /// Carries the per-(agent, namespace) daily memory-write quota, the
3016    /// lifetime storage cap, the daily link-creation quota, and the
3017    /// list/bulk request page-size cap. Resolved via
3018    /// [`AppConfig::resolve_limits`]; the resolver applies the uniform
3019    /// precedence ladder (`AI_MEMORY_MAX_*` env > `[limits]` section >
3020    /// compiled default). Defaults are deliberately generous so the
3021    /// substrate is invisible to small-scale operators; operators with
3022    /// high event-rate workloads raise them per-deployment without
3023    /// recompiling. See [`LimitsSection`].
3024    pub limits: Option<LimitsSection>,
3025}
3026
3027// #1454 (SEC, LOW) — manual `Debug` so the `api_key` secret renders as
3028// `<redacted>` instead of leaking through a `{:?}` of the whole config
3029// (mirrors the `ResolvedLlm` redaction model further down this file).
3030// Every other field is rendered verbatim. KEEP IN SYNC: a new field on
3031// `AppConfig` must be mirrored here or it silently drops from Debug.
3032#[allow(deprecated)] // legacy flat fields are deprecated but still debugged
3033impl std::fmt::Debug for AppConfig {
3034    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3035        f.debug_struct("AppConfig")
3036            .field("tier", &self.tier)
3037            .field("db", &self.db)
3038            .field(config_keys::OLLAMA_URL, &self.ollama_url)
3039            .field("embed_url", &self.embed_url)
3040            .field(config_keys::EMBEDDING_MODEL, &self.embedding_model)
3041            .field("llm_model", &self.llm_model)
3042            .field(config_keys::AUTO_TAG_MODEL, &self.auto_tag_model)
3043            .field(config_keys::CROSS_ENCODER, &self.cross_encoder)
3044            .field(config_keys::DEFAULT_NAMESPACE, &self.default_namespace)
3045            .field(config_keys::MAX_MEMORY_MB, &self.max_memory_mb)
3046            .field("ttl", &self.ttl)
3047            .field(config_keys::ARCHIVE_ON_GC, &self.archive_on_gc)
3048            .field(
3049                "api_key",
3050                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3051            )
3052            .field(config_keys::ARCHIVE_MAX_DAYS, &self.archive_max_days)
3053            .field("identity", &self.identity)
3054            .field("scoring", &self.scoring)
3055            .field("autonomous_hooks", &self.autonomous_hooks)
3056            .field("logging", &self.logging)
3057            .field("audit", &self.audit)
3058            .field("boot", &self.boot)
3059            .field("mcp", &self.mcp)
3060            .field("permissions", &self.permissions)
3061            .field("transcripts", &self.transcripts)
3062            .field("hooks", &self.hooks)
3063            .field("subscriptions", &self.subscriptions)
3064            .field("verify", &self.verify)
3065            .field(
3066                "postgres_statement_timeout_secs",
3067                &self.postgres_statement_timeout_secs,
3068            )
3069            .field(
3070                "postgres_pool_max_connections",
3071                &self.postgres_pool_max_connections,
3072            )
3073            .field(
3074                "postgres_pool_min_connections",
3075                &self.postgres_pool_min_connections,
3076            )
3077            .field(
3078                "postgres_acquire_timeout_secs",
3079                &self.postgres_acquire_timeout_secs,
3080            )
3081            .field("request_timeout_secs", &self.request_timeout_secs)
3082            .field("llm_call_timeout_secs", &self.llm_call_timeout_secs)
3083            .field(
3084                "mcp_federation_forward_url",
3085                &self.mcp_federation_forward_url,
3086            )
3087            .field("agents", &self.agents)
3088            .field("governance", &self.governance)
3089            .field("confidence", &self.confidence)
3090            .field("admin", &self.admin)
3091            .field("schema_version", &self.schema_version)
3092            .field("llm", &self.llm)
3093            .field(config_keys::SECTION_EMBEDDINGS, &self.embeddings)
3094            .field("reranker", &self.reranker)
3095            .field("storage", &self.storage)
3096            .field("limits", &self.limits)
3097            .finish()
3098    }
3099}
3100
3101impl AppConfig {
3102    /// #1258 — manually zeroize the `api_key` buffer. Callers that hold
3103    /// the only owner of an `AppConfig` and are about to drop it
3104    /// invoke this immediately before scope-exit so the secret bytes
3105    /// do not linger on the heap. The free-standing helper (instead of
3106    /// a blanket `Drop` impl on `AppConfig`) preserves the
3107    /// `..AppConfig::default()` struct-update syntax used by ~20
3108    /// existing test sites; adding a blanket `Drop` would forbid the
3109    /// move-by-spread pattern Rust requires for `Drop` types.
3110    pub fn zeroize_secrets(&mut self) {
3111        use zeroize::Zeroize;
3112        if let Some(key) = self.api_key.as_mut() {
3113            key.zeroize();
3114        }
3115    }
3116}
3117
3118/// v0.7.0 SEC-2 (Cluster D, issue #767) — `[governance]` top-level
3119/// block. Today exposes a single fail-closed knob; future governance
3120/// knobs (e.g., signature-rotation policy timestamps, per-rule
3121/// override timeouts) can stack here.
3122///
3123/// Wire format:
3124/// ```toml
3125/// [governance]
3126/// require_operator_pubkey = true
3127/// ```
3128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3129pub struct GovernanceConfig {
3130    /// SEC-2 fail-closed switch. When `true`, the daemon refuses to
3131    /// start if the `governance_rules` table contains any
3132    /// `enabled = 1` row AND no operator pubkey is resolved. Default
3133    /// `false` preserves the pre-cluster-D contract that the
3134    /// substrate stays in pre-L1-6 mode (every enabled rule passes
3135    /// through) until the operator activates L1-6 by placing the
3136    /// pubkey on disk or setting `AI_MEMORY_OPERATOR_PUBKEY`.
3137    ///
3138    /// Operators running the install-script default deploy who want
3139    /// strict enforcement BEFORE the operator pubkey lands set this
3140    /// to `true` — the daemon will then surface a clear error
3141    /// message naming the missing pubkey path.
3142    #[serde(default)]
3143    pub require_operator_pubkey: bool,
3144}
3145
3146/// v0.7.0 SHIP cluster (#946 / #957 / #960 / #961, 2026-05-20) —
3147/// `[admin]` top-level block. The operator-configured allowlist of
3148/// `agent_ids` whose authenticated HTTP requests are treated as
3149/// admin-class callers, granting full cross-tenant visibility on
3150/// endpoints whose payloads necessarily expose corpus-scale
3151/// metadata (`GET /api/v1/export`, `GET /api/v1/agents`,
3152/// `GET /api/v1/stats`, the `POST /api/v1/quota/status` list path).
3153///
3154/// Wire format:
3155/// ```toml
3156/// [admin]
3157/// agent_ids = ["ops:admin", "ai:claude@workstation"]
3158/// ```
3159///
3160/// **Default-closed.** When the block is absent, the allowlist is
3161/// empty and every admin-class endpoint returns `403 Forbidden` for
3162/// every caller. Operators MUST set `[admin].agent_ids = [...]`
3163/// explicitly to grant any caller admin privileges. This closes
3164/// the v0.7.0 SHIP-blocking cross-tenant exfiltration defects
3165/// (#946 / #957 / #960) where admin endpoints landed open by default
3166/// because the legacy `api_key_auth` middleware passes through when
3167/// no API key is configured.
3168///
3169/// **Caller resolution** uses the same primitive other handlers do
3170/// (`identity::resolve_http_agent_id` against `X-Agent-Id`). The
3171/// allowlist matches against the resolved caller string verbatim;
3172/// there is no glob / prefix support today (planned under #961 when
3173/// the operator surface grows beyond a static list).
3174///
3175/// **Not a substitute for authentication.** The role gate runs
3176/// AFTER `api_key_auth`. Deployments serving sensitive corpora
3177/// MUST set `api_key` so the bare-network surface requires the key
3178/// AND the role gate runs on top of it. The two layers compose:
3179/// `api_key_auth` answers "is the request authenticated?" and the
3180/// admin gate answers "is the authenticated caller an admin?".
3181#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3182pub struct AdminConfig {
3183    /// Explicit list of `agent_id` strings whose authenticated
3184    /// requests are treated as admin-class. Default `vec![]`
3185    /// (empty) means no caller is an admin — every admin-class
3186    /// endpoint returns 403.
3187    ///
3188    /// Each entry MUST match a caller's resolved `agent_id`
3189    /// verbatim. Validation: the SAL accepts the same NHI
3190    /// `agent_id` charset that
3191    /// [`crate::validate::validate_agent_id`] enforces (see the
3192    /// "Agent Identity (NHI)" section of CLAUDE.md). Entries that
3193    /// fail validation at boot are logged at `warn` and dropped
3194    /// from the in-memory allowlist; the daemon still starts so
3195    /// a single typo does not lock the operator out.
3196    #[serde(default)]
3197    pub agent_ids: Vec<String>,
3198}
3199
3200impl AdminConfig {
3201    /// Returns the validated subset of `agent_ids` — entries that
3202    /// pass [`crate::validate::validate_agent_id`]. Entries that
3203    /// fail validation are dropped (with a `warn` log) so a single
3204    /// typo in `config.toml` cannot lock the operator out.
3205    #[must_use]
3206    pub fn validated_agent_ids(&self) -> Vec<String> {
3207        let mut out = Vec::with_capacity(self.agent_ids.len());
3208        for id in &self.agent_ids {
3209            match crate::validate::validate_agent_id(id) {
3210                Ok(()) => out.push(id.clone()),
3211                Err(e) => {
3212                    tracing::warn!("[admin] dropping invalid agent_id '{id}' from allowlist: {e}");
3213                }
3214            }
3215        }
3216        out
3217    }
3218}
3219
3220// ---------------------------------------------------------------------------
3221// v0.7.x enterprise configuration sections (issue #1146)
3222//
3223// `[llm]` / `[embeddings]` / `[reranker]` / `[storage]` consolidate
3224// previously-flat LLM / embedder / reranker / storage knobs into a
3225// uniform sectioned shape consumed by the canonical resolvers in
3226// `impl AppConfig`. See the issue for the full design rationale,
3227// migration plan, and acceptance criteria.
3228// ---------------------------------------------------------------------------
3229
3230/// v0.7.x (#1146) — `[llm]` sectioned LLM configuration.
3231///
3232/// Wire format:
3233/// ```toml
3234/// [llm]
3235/// backend     = "xai"          # ollama | openai | xai | anthropic | gemini | …
3236/// model       = "grok-4.3"     # vendor-specific identifier
3237/// base_url    = "https://api.x.ai/v1"   # optional; vendor-default if unset
3238/// api_key_env = "XAI_API_KEY"           # env var name (mutually exclusive
3239///                                        # with api_key_file)
3240/// # api_key_file = "/etc/ai-memory/keys/xai.key"   # mode 0400 enforced
3241///
3242/// [llm.auto_tag]
3243/// # Fast structured-output sibling (auto_tag, query expansion,
3244/// # contradiction detection). Fields fall back to parent [llm]
3245/// # field-by-field when unset; commonly only `model` is overridden.
3246/// model = "gemma3:4b"
3247/// ```
3248///
3249/// **Secret handling discipline.** Inline `api_key = "<literal>"` is
3250/// REJECTED at parse time — operators MUST use either
3251/// `api_key_env = "<ENV_VAR_NAME>"` (resolved at runtime) or
3252/// `api_key_file = "/path/to/key"` (mode 0400 enforced, override via
3253/// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1`). Both unset selects
3254/// the per-vendor-alias env-var fallback chain (see `src/llm.rs`
3255/// `alias_api_key_env_vars`).
3256///
3257/// **Precedence.** Resolved via [`AppConfig::resolve_llm`] through the
3258/// uniform precedence ladder: CLI flag > `AI_MEMORY_LLM_*` env vars >
3259/// `[llm]` section > legacy flat fields (`llm_model`, `ollama_url`) >
3260/// compiled default (warn-logged once on the resolver's `CompiledDefault`
3261/// arm).
3262#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3263pub struct LlmSection {
3264    /// Backend selector. One of: `ollama` (native `/api/chat` +
3265    /// `/api/embed`, no auth), `openai-compatible` (generic; requires
3266    /// explicit `base_url`), or an alias that pre-fills `base_url`
3267    /// (`openai`, `xai`, `anthropic`, `gemini`, `deepseek`, `kimi`,
3268    /// `qwen`, `mistral`, `groq`, `together`, `cerebras`, `openrouter`,
3269    /// `fireworks`, `lmstudio`). Unset = inherit legacy resolution
3270    /// (treated as `ollama`).
3271    pub backend: Option<String>,
3272
3273    /// Model identifier passed verbatim to the chat endpoint.
3274    /// Vendor-specific (e.g., `grok-4.3`, `gpt-5`, `claude-opus-4.7`).
3275    /// Unset = backend-specific default (see `OllamaClient::from_env`).
3276    pub model: Option<String>,
3277
3278    /// Optional base-URL override. Required when `backend =
3279    /// "openai-compatible"`; ignored otherwise (vendor-default
3280    /// applies). For `backend = "ollama"`, defaults to
3281    /// `http://localhost:11434`.
3282    pub base_url: Option<String>,
3283
3284    /// Name of the environment variable to read at runtime for the
3285    /// API-key Bearer auth secret. Mutually exclusive with
3286    /// `api_key_file`. Example: `api_key_env = "XAI_API_KEY"`. The
3287    /// `AI_MEMORY_LLM_API_KEY` process-env override (and the
3288    /// per-vendor fallback chain at `src/llm.rs`
3289    /// `alias_api_key_env_vars`) take precedence over this field per
3290    /// the uniform precedence ladder.
3291    pub api_key_env: Option<String>,
3292
3293    /// Path to a file whose first line is the API-key Bearer secret.
3294    /// Mutually exclusive with `api_key_env`. File must be `mode 0400`
3295    /// or stricter (overridable via
3296    /// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1` per #1055). Tilde
3297    /// expansion applies.
3298    pub api_key_file: Option<String>,
3299
3300    /// **REJECTED AT PARSE TIME.** Accepting the field name here lets
3301    /// the validator emit a clear "use api_key_env or api_key_file"
3302    /// error instead of serde's generic "unknown field". Operators
3303    /// inlining secrets in the config file see the security-rationale
3304    /// message at load time.
3305    #[serde(default)]
3306    pub api_key: Option<String>,
3307
3308    /// `[llm.auto_tag]` sub-table for the fast structured-output
3309    /// sibling (`auto_tag`, query expansion, contradiction detection).
3310    /// Unset = inherit every field from the parent [`LlmSection`].
3311    /// When set, only the explicitly-provided fields override; unset
3312    /// fields fall back to the parent.
3313    #[serde(default)]
3314    pub auto_tag: Option<LlmAutoTagSection>,
3315}
3316
3317// #1454 (SEC, LOW) — manual `Debug` redacts the parse-time-rejected
3318// inline `api_key` so a `{:?}` of an `LlmSection` never echoes a secret
3319// (mirrors `ResolvedLlm`). `api_key_env` / `api_key_file` are env-var
3320// names / file paths (config, not secret) and stay verbatim. KEEP IN
3321// SYNC: a new field must be mirrored here or it drops from Debug.
3322impl std::fmt::Debug for LlmSection {
3323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3324        f.debug_struct("LlmSection")
3325            .field("backend", &self.backend)
3326            .field("model", &self.model)
3327            .field("base_url", &self.base_url)
3328            .field("api_key_env", &self.api_key_env)
3329            .field("api_key_file", &self.api_key_file)
3330            .field(
3331                "api_key",
3332                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3333            )
3334            .field("auto_tag", &self.auto_tag)
3335            .finish()
3336    }
3337}
3338
3339/// v0.7.x (#1146) — `[llm.auto_tag]` sub-table. Fast structured-output
3340/// sibling of [`LlmSection`]. Fields fall back to the parent `[llm]`
3341/// section field-by-field when unset; commonly only `model` is
3342/// overridden to point at a faster model (default `gemma3:4b`,
3343/// ~0.7s p50 vs ~15s p50 for thinking-mode Gemma 4 per L15 patch).
3344#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3345pub struct LlmAutoTagSection {
3346    /// Backend override. Unset = inherit `[llm].backend`.
3347    pub backend: Option<String>,
3348    /// Model override. Unset = inherit `[llm].model`. Compiled default
3349    /// at the resolver level is `gemma3:4b` (L15 fast-structured-output
3350    /// model selection).
3351    pub model: Option<String>,
3352    /// Base-URL override. Unset = inherit `[llm].base_url`.
3353    pub base_url: Option<String>,
3354    /// Env-var-name override for the API key. Unset = inherit
3355    /// `[llm].api_key_env` (or `[llm].api_key_file`).
3356    pub api_key_env: Option<String>,
3357    /// File-path override for the API key. Unset = inherit
3358    /// `[llm].api_key_file` (or `[llm].api_key_env`).
3359    pub api_key_file: Option<String>,
3360}
3361
3362/// v0.7.x (#1146) — `[embeddings]` sectioned embedding-model
3363/// configuration.
3364///
3365/// Wire format:
3366/// ```toml
3367/// [embeddings]
3368/// backend        = "openrouter"                # ollama (default) or any
3369///                                              # #1067 API alias /
3370///                                              # openai-compatible (#1598)
3371/// base_url       = "https://openrouter.ai/api/v1"
3372/// model          = "google/gemini-embedding-2"
3373/// api_key_env    = "OPENROUTER_API_KEY"        # mutually exclusive with
3374/// # api_key_file = "/etc/ai-memory/keys/embed.key"   # mode 0400 enforced
3375/// dim            = 3072                        # only needed for models
3376///                                              # outside the known-dims table
3377/// backfill_batch = 100                         # 1-10000 (env override:
3378///                                              # AI_MEMORY_EMBED_BACKFILL_BATCH)
3379/// ```
3380#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3381pub struct EmbeddingsSection {
3382    /// Embedding backend. `ollama` (the default — local `/api/embed`
3383    /// wire shape) or, since #1598, any #1067 OpenAI-compatible alias
3384    /// (`openrouter`, `openai`, `gemini`, …) or the generic
3385    /// `openai-compatible` escape hatch for self-hosted endpoints
3386    /// (HF TEI, vLLM).
3387    pub backend: Option<String>,
3388
3389    /// Embedding endpoint URL. Defaults to `http://localhost:11434`
3390    /// when unset (ollama backend) or to the backend alias's default
3391    /// base URL (API backends, #1598). Synonym of [`Self::base_url`];
3392    /// `base_url` wins when both are set.
3393    pub url: Option<String>,
3394
3395    /// #1598 — embedding endpoint base URL. Synonym of [`Self::url`]
3396    /// (named to match `[llm].base_url`); when both are set,
3397    /// `base_url` wins.
3398    pub base_url: Option<String>,
3399
3400    /// Embedding model identifier. Legacy values `nomic_embed_v15`
3401    /// (alias for `nomic-embed-text-v1.5`) and `mini_lm_l6_v2` (alias
3402    /// for `sentence-transformers/all-MiniLM-L6-v2`) are honored at
3403    /// parse time.
3404    pub model: Option<String>,
3405
3406    /// #1598 — inline API-key literal. ALWAYS REJECTED at config load
3407    /// (mirrors `[llm].api_key`): config.toml is typically
3408    /// world-readable, so inline secrets are a credential leak. The
3409    /// field exists solely so the rejection is loud instead of a
3410    /// silent unknown-key skip. Use [`Self::api_key_env`] or
3411    /// [`Self::api_key_file`].
3412    pub api_key: Option<String>,
3413
3414    /// #1598 — name of the process env var holding the embedding API
3415    /// key. Mutually exclusive with [`Self::api_key_file`].
3416    pub api_key_env: Option<String>,
3417
3418    /// #1598 — path of a file holding the embedding API key (mode
3419    /// 0400 enforced, mirroring `[llm].api_key_file`). Mutually
3420    /// exclusive with [`Self::api_key_env`].
3421    pub api_key_file: Option<String>,
3422
3423    /// #1598 — explicit vector-dim override for embedding models not
3424    /// in [`KNOWN_EMBEDDING_DIMS`]. Takes precedence over the table
3425    /// lookup; non-positive values are ignored.
3426    pub dim: Option<u32>,
3427
3428    /// Backfill batch size. Bounded `1..=10000`; out-of-range values
3429    /// fall back to the compiled default (100) with a WARN. Env
3430    /// override: `AI_MEMORY_EMBED_BACKFILL_BATCH` (#38).
3431    pub backfill_batch: Option<u32>,
3432}
3433
3434/// v0.7.x (#1146) — `[reranker]` sectioned cross-encoder
3435/// configuration.
3436///
3437/// Wire format:
3438/// ```toml
3439/// [reranker]
3440/// enabled = true
3441/// model   = "ms-marco-MiniLM-L-6-v2"   # v0.7.0 has one variant;
3442///                                       # field reserved for future
3443///                                       # bake-offs.
3444/// ```
3445///
3446/// Folds the legacy `cross_encoder = bool` top-level flag. Migration
3447/// (via `ai-memory config migrate`) writes the explicit `enabled` +
3448/// `model` fold; the legacy field continues to be honored at parse
3449/// time until v0.8.0.
3450#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3451pub struct RerankerSection {
3452    /// Whether the cross-encoder rerank stage runs in the recall
3453    /// pipeline. Folded from `cross_encoder: Option<bool>` at the
3454    /// resolver layer.
3455    pub enabled: Option<bool>,
3456
3457    /// Cross-encoder model identifier. Defaults to
3458    /// `ms-marco-MiniLM-L-6-v2` when unset. Field reserved for future
3459    /// model bake-offs (e.g., `bge-reranker-v2-m3`,
3460    /// `mxbai-rerank-large-v2`).
3461    pub model: Option<String>,
3462
3463    /// #1604 — tokenized length cap for rerank inputs (the batched
3464    /// cross-encoder forward). Defaults to
3465    /// `crate::reranker::RERANK_MAX_SEQ_DEFAULT` when unset; values
3466    /// that are zero or above the model ceiling
3467    /// (`crate::reranker::CROSS_ENCODER_MAX_SEQ`) fall through.
3468    /// Overridable via `AI_MEMORY_RERANK_MAX_SEQ`.
3469    pub max_seq_tokens: Option<usize>,
3470
3471    /// #1691/n14 — recall-reranker score floor: drops low-confidence
3472    /// rerank candidates below a threshold so noise-band paraphrase
3473    /// matches do not surface. Value grammar (case-insensitive):
3474    /// `off` (default) | `absolute:<f>` (drop below an absolute blended
3475    /// score) | `relative:<f>` (drop below `top_score * f`). Parsed via
3476    /// [`crate::reranker::RerankerScoreFloor::parse`] and fed to
3477    /// [`crate::reranker::BatchedReranker::with_score_floor`] at every
3478    /// reranker build site. Overridable via `AI_MEMORY_RERANK_SCORE_FLOOR`.
3479    pub score_floor: Option<String>,
3480}
3481
3482/// #1671/n15 (v0.7.1) — `[curator]` sectioned per-namespace curator
3483/// configuration.
3484///
3485/// Wire format:
3486/// ```toml
3487/// # Per-namespace reflection-pass gate (#1671). `curator --reflect
3488/// # --all-namespaces` reflects ONLY namespaces listed here with
3489/// # `enabled = true`; a single `--namespace <ns>` invocation bypasses
3490/// # the gate (operator asked explicitly).
3491/// [curator.reflection_namespaces."team/eng"]
3492/// enabled = true
3493/// max_depth = 5
3494///
3495/// # Per-namespace confidence-decay half-life override, in days (n15).
3496/// # Absent → the compiled DEFAULT_HALF_LIFE_DAYS (30). Only consulted
3497/// # when the decay feature is enabled (AI_MEMORY_CONFIDENCE_DECAY=1).
3498/// [curator.confidence_decay_half_life_days]
3499/// "team/eng" = 14.0
3500/// ```
3501#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3502pub struct CuratorSection {
3503    /// #1671 — per-namespace reflection-pass overrides keyed by
3504    /// namespace. `curator --reflect --all-namespaces` participates ONLY
3505    /// for namespaces present here with `enabled = true`; absent /
3506    /// disabled namespaces are skipped (the conservative default that
3507    /// kept `--all-namespaces` a safe no-op before this wiring). Reuses
3508    /// the curator's own
3509    /// [`crate::curator::reflection_pass::ReflectionPassConfig`].
3510    #[serde(default)]
3511    pub reflection_namespaces: Option<
3512        std::collections::HashMap<String, crate::curator::reflection_pass::ReflectionPassConfig>,
3513    >,
3514
3515    /// n15 — per-namespace confidence-decay half-life override (days),
3516    /// keyed by namespace. Absent / non-finite / non-positive → the
3517    /// compiled [`crate::confidence::DEFAULT_HALF_LIFE_DAYS`]. Only
3518    /// consulted when confidence decay is enabled
3519    /// (`AI_MEMORY_CONFIDENCE_DECAY=1`).
3520    #[serde(default)]
3521    pub confidence_decay_half_life_days: Option<std::collections::HashMap<String, f64>>,
3522}
3523
3524/// v0.7.x (#1146) — `[storage]` sectioned storage configuration.
3525///
3526/// Wire format:
3527/// ```toml
3528/// [storage]
3529/// default_namespace = "alphaone"
3530/// archive_on_gc     = true
3531/// archive_max_days  = 90
3532/// max_memory_mb     = 4096
3533/// ```
3534///
3535/// Carries the previously-flat top-level fields `default_namespace`,
3536/// `archive_on_gc`, `archive_max_days`, `max_memory_mb`. The `db`
3537/// path stays top-level per the #1146 I4 carve-out (path expansion
3538/// semantics pinned by #507).
3539#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3540pub struct StorageSection {
3541    /// Default namespace for new memories when the caller's request
3542    /// omits one. Folded from the previously-flat top-level
3543    /// `default_namespace` field.
3544    pub default_namespace: Option<String>,
3545
3546    /// Whether to archive memories before GC deletion. Folded from
3547    /// `archive_on_gc`. Default `true`.
3548    pub archive_on_gc: Option<bool>,
3549
3550    /// Archive retention ceiling in days. `None` (default) disables
3551    /// the automatic purge. Folded from `archive_max_days`.
3552    pub archive_max_days: Option<i64>,
3553
3554    /// Memory budget in MB for the auto tier selector. Folded from
3555    /// `max_memory_mb`.
3556    pub max_memory_mb: Option<usize>,
3557
3558    /// #1579 B7 — sqlite `PRAGMA mmap_size` in bytes. `0` disables
3559    /// memory-mapped I/O (stock SQLite semantics); negative values are
3560    /// treated as unset and fall through the ladder. Env override:
3561    /// `AI_MEMORY_DB_MMAP_SIZE` (see [`ENV_DB_MMAP_SIZE`]). Compiled
3562    /// default: 256 MiB
3563    /// ([`crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES`]) — the only
3564    /// across-the-board winner of the P1 perf-audit PRAGMA A/B
3565    /// (15-30% on large-corpus reads).
3566    pub db_mmap_size_bytes: Option<i64>,
3567}
3568
3569/// v0.7.x — `[limits]` sectioned operator-tunable capacity limits.
3570///
3571/// Wire format:
3572/// ```toml
3573/// [limits]
3574/// max_memories_per_day = 10000000   # per-(agent, namespace) daily write quota
3575/// max_storage_bytes    = 1073741824 # per-(agent, namespace) lifetime byte cap
3576/// max_links_per_day    = 5000       # per-(agent, namespace) daily link quota
3577/// max_page_size        = 1000       # list/bulk request page-size ceiling
3578/// ```
3579///
3580/// Every field is optional; an omitted (or non-positive) value falls
3581/// through to the compiled default (`crate::quotas::DEFAULT_MAX_*` for
3582/// the three quota knobs, [`crate::handlers::MAX_BULK_SIZE`] for the
3583/// page-size cap). Resolved via [`AppConfig::resolve_limits`], which
3584/// also honours the `AI_MEMORY_MAX_*` env overrides at higher
3585/// precedence than the section.
3586///
3587/// **Operator guidance for `max_page_size`.** This bounds the number of
3588/// rows materialised into a single HTTP list response AND the number of
3589/// items accepted in a single bulk / federation-sync request. It is a
3590/// per-request in-memory bound, NOT a rate limit: a single request that
3591/// asks for (or carries) millions of rows allocates them all at once.
3592/// Raise it for bulk verification of a known-small corpus; for
3593/// genuinely large datasets paginate with `?offset=` / `?since=` rather
3594/// than removing the bound.
3595#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3596pub struct LimitsSection {
3597    /// Per-(agent, namespace) daily memory-write ceiling stamped at
3598    /// quota-row auto-insert. Folds nothing legacy; new at v0.7.x.
3599    pub max_memories_per_day: Option<i64>,
3600
3601    /// Per-(agent, namespace) lifetime storage cap in bytes.
3602    pub max_storage_bytes: Option<i64>,
3603
3604    /// Per-(agent, namespace) daily link-creation ceiling.
3605    pub max_links_per_day: Option<i64>,
3606
3607    /// Maximum items returned in a single list response / accepted in a
3608    /// single bulk or federation-sync request.
3609    pub max_page_size: Option<usize>,
3610}
3611
3612// ---------------------------------------------------------------------------
3613// Resolved-config shapes (#1146)
3614//
3615// Every surface that needs LLM / embedder / reranker / storage config
3616// consumes one of the `Resolved*` shapes below. The resolver methods on
3617// `AppConfig` (`resolve_llm` / `resolve_embeddings` / `resolve_reranker`
3618// / `resolve_storage`) produce them by applying the uniform precedence
3619// ladder:
3620//
3621//   CLI flag  >  AI_MEMORY_* env var  >  config.toml section
3622//             >  legacy flat fields (with deprecation WARN once)
3623//             >  compiled default (CompiledDefault arm, WARN once)
3624//
3625// Resolvers are PURE (no network I/O). File reads for `api_key_file`
3626// happen at resolve time and surface errors via the `KeySource::Error`
3627// variant rather than panicking, so the daemon can boot and report the
3628// problem via the doctor reachability probe rather than failing at
3629// load time.
3630// ---------------------------------------------------------------------------
3631
3632/// Provenance tag for a resolved `Resolved*` field's value, surfaced by
3633/// the boot banner and `ai-memory doctor` so operators can see WHICH
3634/// source won the precedence ladder.
3635#[derive(Debug, Clone, PartialEq, Eq)]
3636pub enum ConfigSource {
3637    /// CLI flag (highest precedence).
3638    Cli,
3639    /// `AI_MEMORY_*` process environment variable.
3640    Env,
3641    /// `[llm]` / `[embeddings]` / `[reranker]` / `[storage]` section
3642    /// in `~/.config/ai-memory/config.toml`.
3643    Config,
3644    /// Legacy flat field in `~/.config/ai-memory/config.toml` (e.g.
3645    /// `llm_model = "gemma4:e4b"`). Triggers a one-shot deprecation
3646    /// WARN on `Config::load`.
3647    Legacy,
3648    /// Compiled-in default (no operator configuration). Triggers a
3649    /// one-shot WARN at resolve time so silent misconfigurations are
3650    /// loud.
3651    CompiledDefault,
3652}
3653
3654impl ConfigSource {
3655    #[must_use]
3656    pub fn as_str(&self) -> &'static str {
3657        match self {
3658            Self::Cli => "cli",
3659            Self::Env => "env",
3660            Self::Config => "config",
3661            Self::Legacy => "legacy",
3662            Self::CompiledDefault => "compiled-default",
3663        }
3664    }
3665}
3666
3667/// Provenance tag for a resolved API-key value.
3668#[derive(Debug, Clone, PartialEq, Eq)]
3669pub enum KeySource {
3670    /// `AI_MEMORY_LLM_API_KEY` process env var (highest precedence).
3671    ProcessEnv,
3672    /// Per-vendor process env-var fallback (`XAI_API_KEY`,
3673    /// `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.). The string field
3674    /// carries the name of the var that won (for observability).
3675    AliasFallback(String),
3676    /// `[llm].api_key_env` config-pointed env var. The string field
3677    /// carries the resolved env-var name.
3678    ConfigEnvVar(String),
3679    /// `[llm].api_key_file` config-pointed file path. The string field
3680    /// carries the resolved (tilde-expanded) path.
3681    ConfigFile(String),
3682    /// No API key resolved. Correct for `backend = "ollama"`
3683    /// (no auth); a misconfiguration for OpenAI-compatible vendors.
3684    None,
3685    /// Error reading the resolved key source. The string carries the
3686    /// human-readable error for the doctor probe to surface.
3687    Error(String),
3688}
3689
3690impl KeySource {
3691    #[must_use]
3692    pub fn as_str(&self) -> &'static str {
3693        match self {
3694            Self::ProcessEnv => "process-env",
3695            Self::AliasFallback(_) => "alias-fallback",
3696            Self::ConfigEnvVar(_) => "config-env-var",
3697            Self::ConfigFile(_) => "config-file",
3698            Self::None => "none",
3699            Self::Error(_) => "error",
3700        }
3701    }
3702
3703    /// True when the key was resolved from any source.
3704    #[must_use]
3705    pub fn is_present(&self) -> bool {
3706        !matches!(self, Self::None | Self::Error(_))
3707    }
3708}
3709
3710/// Canonical resolved-LLM configuration. Produced by
3711/// [`AppConfig::resolve_llm`]. Every LLM-init surface (MCP stdio,
3712/// HTTP daemon, `ai-memory atomise`, `ai-memory curator`,
3713/// embed-client fallback, boot banner) consumes this struct rather
3714/// than reading raw config / env / tier presets.
3715///
3716/// **Secret handling.** The `api_key` field is private; access via
3717/// `api_key()`. The `Debug` impl redacts the value (`<redacted>`).
3718#[derive(Clone, PartialEq, Eq)]
3719pub struct ResolvedLlm {
3720    /// Backend alias / wire-shape selector (e.g. `"ollama"`, `"xai"`,
3721    /// `"openai-compatible"`).
3722    pub backend: String,
3723    /// Model identifier passed verbatim to the chat endpoint.
3724    pub model: String,
3725    /// Base URL of the chat endpoint (vendor-default or operator
3726    /// override).
3727    pub base_url: String,
3728    /// Resolved API key. `None` for `backend = "ollama"` and for
3729    /// misconfigured backends; `Some` otherwise. Private — access via
3730    /// [`Self::api_key`] to keep accidental `{:?}` prints from
3731    /// leaking the value.
3732    api_key: Option<String>,
3733    /// Provenance of the resolved API key for boot-banner /
3734    /// doctor-probe display.
3735    pub api_key_source: KeySource,
3736    /// Provenance of the resolved configuration (CLI / env / config /
3737    /// legacy / compiled-default).
3738    pub source: ConfigSource,
3739}
3740
3741impl ResolvedLlm {
3742    /// Access the resolved API key. Use this only when constructing
3743    /// the LLM client; do NOT log or `{:?}` the result.
3744    #[must_use]
3745    pub fn api_key(&self) -> Option<&str> {
3746        self.api_key.as_deref()
3747    }
3748
3749    /// True when the resolved backend uses the Ollama-native wire
3750    /// shape (`/api/chat`, `/api/embed`, no auth). False for any
3751    /// OpenAI-compatible vendor.
3752    ///
3753    /// Compares `self.backend` against the canonical
3754    /// [`crate::llm::BACKEND_OLLAMA`] selector (#1174 PR4 substrate
3755    /// cleanup) so the literal lives in `llm.rs` alongside the rest
3756    /// of the vendor-alias tables instead of being re-named at each
3757    /// substrate site.
3758    #[must_use]
3759    pub fn is_ollama_native(&self) -> bool {
3760        self.backend == crate::llm::BACKEND_OLLAMA
3761    }
3762
3763    /// Display string for the boot banner: `<backend>:<model>`.
3764    #[must_use]
3765    pub fn display_label(&self) -> String {
3766        format!("{}:{}", self.backend, self.model)
3767    }
3768}
3769
3770impl std::fmt::Debug for ResolvedLlm {
3771    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3772        f.debug_struct("ResolvedLlm")
3773            .field("backend", &self.backend)
3774            .field("model", &self.model)
3775            .field("base_url", &self.base_url)
3776            .field(
3777                "api_key",
3778                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3779            )
3780            .field("api_key_source", &self.api_key_source)
3781            .field("source", &self.source)
3782            .finish()
3783    }
3784}
3785
3786/// Canonical resolved-embedder configuration. Produced by
3787/// [`AppConfig::resolve_embeddings`].
3788///
3789/// **Secret handling (#1598).** The `api_key` field is private;
3790/// access via [`Self::api_key`]. The manual `Debug` impl redacts the
3791/// value (`<redacted>`), mirroring [`ResolvedLlm`].
3792#[derive(Clone, PartialEq, Eq)]
3793pub struct ResolvedEmbeddings {
3794    /// Embedding backend selector. `"ollama"` (local `/api/embed`
3795    /// wire shape) or, since #1598, any #1067 OpenAI-compatible alias
3796    /// / the generic `openai-compatible` escape hatch. Classify via
3797    /// [`is_api_embed_backend`].
3798    pub backend: String,
3799    /// Embedding endpoint base URL. The `[embeddings].base_url` /
3800    /// `[embeddings].url` synonym merge happens in the resolver
3801    /// (`base_url` wins); the field keeps the historical `url` name
3802    /// to limit call-site churn (#1598).
3803    pub url: String,
3804    /// Embedding model identifier (canonicalised — legacy aliases
3805    /// `nomic_embed_v15` / `mini_lm_l6_v2` are mapped to the
3806    /// `EmbeddingModel` enum's canonical HF id at resolve time).
3807    pub model: String,
3808    /// Backfill batch size. Bounded `1..=10000`; out-of-range values
3809    /// fall back to 100 with a WARN.
3810    pub backfill_batch: u32,
3811    /// v0.7.x (issue #1169) — vector dim of the resolved model, when
3812    /// known. #1598: the explicit `[embeddings].dim` override wins
3813    /// over the [`canonical_embedding_dim`] table lookup. `None` when
3814    /// the operator chose a model id that isn't in the table and set
3815    /// no override — in that case [`build_capability_models`] falls
3816    /// back to the tier preset's dim (preserving pre-#1169 behaviour
3817    /// for unrecognised ids and avoiding the silent-wrong-dim trap
3818    /// for the recognised ones).
3819    pub embedding_dim: Option<u32>,
3820    /// #1598 (fleet follow-up) — the EXPLICIT `[embeddings].dim`
3821    /// override only (never table-derived). For OpenAI-compatible
3822    /// backends this is also sent as the wire `dimensions` request
3823    /// param, so Matryoshka-capable API models (gemini-embedding-2,
3824    /// text-embedding-3-*) return truncated vectors at the operator's
3825    /// declared dim — the mechanism that keeps pgvector `vector(768)`
3826    /// fleet schemas + ANN indexes (≤2000-dim limit) usable with
3827    /// high-dim API models. `None` = model-native dim.
3828    pub requested_dim: Option<u32>,
3829    /// #1598 — resolved embedding API key. `None` for
3830    /// `backend = "ollama"` (no auth) and for keyless self-hosted
3831    /// OpenAI-compatible endpoints. Private — access via
3832    /// [`Self::api_key`].
3833    api_key: Option<String>,
3834    /// #1598 — provenance of the resolved API key for boot-banner /
3835    /// doctor-probe display.
3836    pub key_source: KeySource,
3837    /// Provenance of the resolved configuration.
3838    pub source: ConfigSource,
3839}
3840
3841impl ResolvedEmbeddings {
3842    /// Access the resolved embedding API key. Use this only when
3843    /// constructing the embed client; do NOT log or `{:?}` the result.
3844    #[must_use]
3845    pub fn api_key(&self) -> Option<&str> {
3846        self.api_key.as_deref()
3847    }
3848
3849    /// #1598 — construct from explicit parts. Prefer
3850    /// [`AppConfig::resolve_embeddings`]; this exists for tests and
3851    /// sibling surfaces (e.g. the reembed CLI) that synthesise a
3852    /// resolved view without an `AppConfig`.
3853    #[must_use]
3854    pub fn from_parts(
3855        backend: String,
3856        url: String,
3857        model: String,
3858        embedding_dim: Option<u32>,
3859        api_key: Option<String>,
3860    ) -> Self {
3861        Self {
3862            backend,
3863            url,
3864            model,
3865            backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
3866            embedding_dim,
3867            requested_dim: None,
3868            api_key,
3869            key_source: KeySource::None,
3870            source: ConfigSource::CompiledDefault,
3871        }
3872    }
3873
3874    /// #1598 (fleet follow-up) — builder for the explicit requested
3875    /// output dimensionality (see [`Self::requested_dim`]).
3876    #[must_use]
3877    pub fn with_requested_dim(mut self, dim: Option<u32>) -> Self {
3878        self.requested_dim = dim;
3879        self
3880    }
3881}
3882
3883impl std::fmt::Debug for ResolvedEmbeddings {
3884    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3885        f.debug_struct("ResolvedEmbeddings")
3886            .field("backend", &self.backend)
3887            .field("url", &self.url)
3888            .field("model", &self.model)
3889            .field("backfill_batch", &self.backfill_batch)
3890            .field(
3891                crate::models::field_names::EMBEDDING_DIM,
3892                &self.embedding_dim,
3893            )
3894            .field("requested_dim", &self.requested_dim)
3895            .field(
3896                "api_key",
3897                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3898            )
3899            .field("key_source", &self.key_source)
3900            .field("source", &self.source)
3901            .finish()
3902    }
3903}
3904
3905/// Canonical resolved-reranker configuration. Produced by
3906/// [`AppConfig::resolve_reranker`].
3907#[derive(Debug, Clone, PartialEq, Eq)]
3908pub struct ResolvedReranker {
3909    /// Whether the cross-encoder rerank stage runs.
3910    pub enabled: bool,
3911    /// Cross-encoder model identifier.
3912    pub model: String,
3913    /// #1604 — tokenized length cap for rerank inputs, resolved via
3914    /// `AI_MEMORY_RERANK_MAX_SEQ` env > `[reranker].max_seq_tokens` >
3915    /// `crate::reranker::RERANK_MAX_SEQ_DEFAULT`. Seeded into
3916    /// `crate::reranker::set_rerank_max_seq` at boot.
3917    pub max_seq_tokens: usize,
3918    /// Provenance of the resolved configuration.
3919    pub source: ConfigSource,
3920}
3921
3922/// Canonical resolved-storage configuration. Produced by
3923/// [`AppConfig::resolve_storage`].
3924#[derive(Debug, Clone, PartialEq, Eq)]
3925pub struct ResolvedStorage {
3926    /// Default namespace for new memories when the caller omits one.
3927    pub default_namespace: String,
3928    /// Whether to archive memories before GC deletion.
3929    pub archive_on_gc: bool,
3930    /// Archive retention ceiling in days (`None` = disabled).
3931    pub archive_max_days: Option<i64>,
3932    /// Memory budget in MB for the auto tier selector.
3933    pub max_memory_mb: Option<usize>,
3934    /// #1579 B7 — resolved sqlite `PRAGMA mmap_size` in bytes
3935    /// (`AI_MEMORY_DB_MMAP_SIZE` env > `[storage].db_mmap_size_bytes`
3936    /// > compiled 256 MiB default). `0` disables memory-mapped I/O.
3937    /// Seeded into `crate::storage::set_db_mmap_size` at boot.
3938    pub db_mmap_size_bytes: i64,
3939    /// #1590 — per-field provenance of `default_namespace`:
3940    /// [`ConfigSource::Config`] when `[storage].default_namespace` is
3941    /// explicitly set, [`ConfigSource::Legacy`] when only the
3942    /// deprecated flat `default_namespace` field is set, else
3943    /// [`ConfigSource::CompiledDefault`]. The section-level `source`
3944    /// tag below cannot express this — it reports `Config` whenever a
3945    /// `[storage]` section EXISTS even if `default_namespace` itself
3946    /// was never configured, and the write-path defaulting must only
3947    /// be overridden by an explicit operator choice (unconfigured
3948    /// deployments keep the historical per-surface ladders).
3949    pub default_namespace_source: ConfigSource,
3950    /// Provenance of the resolved configuration.
3951    pub source: ConfigSource,
3952}
3953
3954impl ResolvedStorage {
3955    /// #1590 — the operator-EXPLICITLY-configured default namespace,
3956    /// or `None` when `default_namespace` merely bottomed out at the
3957    /// compiled `"global"` default. Write-path consumers (MCP
3958    /// `memory_store`, HTTP `POST /api/v1/memories`, the CLI
3959    /// namespace ladder) only override their historical defaults when
3960    /// this returns `Some`.
3961    #[must_use]
3962    pub fn explicit_default_namespace(&self) -> Option<&str> {
3963        if self.default_namespace_source == ConfigSource::CompiledDefault {
3964            None
3965        } else {
3966            Some(self.default_namespace.as_str())
3967        }
3968    }
3969}
3970
3971// ---------------------------------------------------------------------------
3972// #1590 — process-wide operator-configured default namespace
3973// ---------------------------------------------------------------------------
3974
3975/// #1590 — process-wide operator-configured default namespace, seeded
3976/// once at boot by `crate::daemon_runtime::run` from
3977/// [`ResolvedStorage::explicit_default_namespace`]. `None` (the
3978/// unseeded / unconfigured state) preserves every surface's historical
3979/// default: MCP + HTTP store fall back to [`crate::DEFAULT_NAMESPACE`]
3980/// and the CLI falls back to its git-remote → cwd-basename → global
3981/// inference ladder. Mirrors the `crate::quotas::QuotaDefaults` /
3982/// `crate::storage::set_db_mmap_size` boot-seeding pattern for knobs
3983/// consumed where no `AppConfig` is in scope (serde default fns, MCP
3984/// param parsing, CLI helpers).
3985static CONFIGURED_DEFAULT_NAMESPACE: std::sync::RwLock<Option<String>> =
3986    std::sync::RwLock::new(None);
3987
3988/// #1590 — seed (or clear) the process-wide operator-configured
3989/// default namespace. Called once at boot; pass `None` for
3990/// deployments without an explicit `[storage].default_namespace`.
3991pub fn set_configured_default_namespace(namespace: Option<String>) {
3992    let mut slot = CONFIGURED_DEFAULT_NAMESPACE
3993        .write()
3994        .unwrap_or_else(std::sync::PoisonError::into_inner);
3995    *slot = namespace.filter(|s| !s.trim().is_empty());
3996}
3997
3998/// #1590 — the operator-configured default namespace, or `None` when
3999/// the operator never explicitly configured one (callers then apply
4000/// their historical per-surface default).
4001#[must_use]
4002pub fn configured_default_namespace() -> Option<String> {
4003    CONFIGURED_DEFAULT_NAMESPACE
4004        .read()
4005        .unwrap_or_else(std::sync::PoisonError::into_inner)
4006        .clone()
4007}
4008
4009/// Test-only gate serialising mutations of the process-wide
4010/// [`CONFIGURED_DEFAULT_NAMESPACE`] slot (same pattern as
4011/// [`lock_permissions_mode_for_test`]). Every test that seeds the slot
4012/// — or asserts the unseeded default — takes this guard first so
4013/// parallel tests cannot observe each other's transient state.
4014pub fn lock_configured_default_namespace_for_test() -> std::sync::MutexGuard<'static, ()> {
4015    static GATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4016    GATE_LOCK
4017        .lock()
4018        .unwrap_or_else(std::sync::PoisonError::into_inner)
4019}
4020
4021/// Canonical resolved operator-tunable capacity limits. Produced by
4022/// [`AppConfig::resolve_limits`]. Consumed at daemon boot to install the
4023/// quota-row auto-insert defaults (`crate::quotas::set_quota_defaults`)
4024/// and the HTTP list/bulk page-size cap (`AppState::max_page_size`).
4025#[derive(Debug, Clone, PartialEq, Eq)]
4026pub struct ResolvedLimits {
4027    /// Per-(agent, namespace) daily memory-write ceiling.
4028    pub max_memories_per_day: i64,
4029    /// Per-(agent, namespace) lifetime storage cap in bytes.
4030    pub max_storage_bytes: i64,
4031    /// Per-(agent, namespace) daily link-creation ceiling.
4032    pub max_links_per_day: i64,
4033    /// Maximum items per list response / bulk-or-sync request.
4034    pub max_page_size: usize,
4035    /// Provenance of the resolved configuration.
4036    pub source: ConfigSource,
4037}
4038
4039/// Env override for `[limits].max_memories_per_day`.
4040pub const ENV_MAX_MEMORIES_PER_DAY: &str = "AI_MEMORY_MAX_MEMORIES_PER_DAY";
4041/// Env override for `[limits].max_storage_bytes`.
4042pub const ENV_MAX_STORAGE_BYTES: &str = "AI_MEMORY_MAX_STORAGE_BYTES";
4043/// Env override for `[limits].max_links_per_day`.
4044pub const ENV_MAX_LINKS_PER_DAY: &str = "AI_MEMORY_MAX_LINKS_PER_DAY";
4045/// Env override for `[limits].max_page_size`.
4046pub const ENV_MAX_PAGE_SIZE: &str = "AI_MEMORY_MAX_PAGE_SIZE";
4047
4048/// #1579 B7 — env override for the sqlite `PRAGMA mmap_size`
4049/// (`[storage].db_mmap_size_bytes`), in whole bytes. `0` disables
4050/// memory-mapped I/O; negative / unparseable values fall through to
4051/// the `[storage]` section, then to the compiled 256 MiB default
4052/// (`crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES`).
4053pub const ENV_DB_MMAP_SIZE: &str = "AI_MEMORY_DB_MMAP_SIZE";
4054
4055/// #1604 — env override for the tokenized length of rerank inputs
4056/// (`[reranker].max_seq_tokens`), in tokens. Values that are zero,
4057/// unparseable, or above the model ceiling
4058/// (`crate::reranker::CROSS_ENCODER_MAX_SEQ`) fall through to the
4059/// `[reranker]` section, then to the compiled default
4060/// (`crate::reranker::RERANK_MAX_SEQ_DEFAULT`).
4061pub const ENV_RERANK_MAX_SEQ: &str = "AI_MEMORY_RERANK_MAX_SEQ";
4062
4063/// #1691/n14 — env override for the recall-reranker score floor.
4064/// Value grammar (case-insensitive): `off` | `absolute:<f>` |
4065/// `relative:<f>` (see [`crate::reranker::RerankerScoreFloor::parse`]).
4066/// Highest-precedence layer of the score-floor ladder
4067/// (env > `[reranker].score_floor` > compiled default
4068/// [`crate::reranker::RerankerScoreFloor::Off`]). Unparseable values
4069/// fall through to the next layer.
4070pub const ENV_RERANK_SCORE_FLOOR: &str = "AI_MEMORY_RERANK_SCORE_FLOOR";
4071
4072/// v0.7.0 (a) — env override for the postgres pool ceiling
4073/// (`postgres_pool_max_connections`). Byte-matches the name documented
4074/// in `docs/enterprise-deployment.md §5.6`.
4075pub const ENV_PG_POOL_MAX: &str = "AI_MEMORY_PG_POOL_MAX";
4076/// v0.7.0 (a) — env override for the postgres pool floor
4077/// (`postgres_pool_min_connections`). Byte-matches the name documented
4078/// in `docs/enterprise-deployment.md §5.6`.
4079pub const ENV_PG_POOL_MIN: &str = "AI_MEMORY_PG_POOL_MIN";
4080/// v0.7.0 (a) — env override for the pool acquire-timeout
4081/// (`postgres_acquire_timeout_secs`), in whole seconds.
4082pub const ENV_PG_ACQUIRE_TIMEOUT_SECS: &str = "AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS";
4083
4084/// #1067 — env carrying the LLM Bearer-auth secret; highest-precedence
4085/// layer of the `[llm]` API-key resolution ladder ([`KeySource`]).
4086pub const ENV_LLM_API_KEY: &str = "AI_MEMORY_LLM_API_KEY";
4087
4088/// #1598 — env override for the embedding backend selector
4089/// (`[embeddings].backend`). Same accepted values as the section
4090/// field: `ollama`, any #1067 alias, or `openai-compatible`.
4091pub const ENV_EMBED_BACKEND: &str = "AI_MEMORY_EMBED_BACKEND";
4092/// #1598 — env override for the embedding endpoint base URL
4093/// (`[embeddings].base_url` / `[embeddings].url`).
4094pub const ENV_EMBED_BASE_URL: &str = "AI_MEMORY_EMBED_BASE_URL";
4095/// #1598 — env override for the embedding model id
4096/// (`[embeddings].model`).
4097pub const ENV_EMBED_MODEL: &str = "AI_MEMORY_EMBED_MODEL";
4098/// #1598 — env carrying the embedding Bearer-auth secret;
4099/// highest-precedence layer of the `[embeddings]` API-key resolution
4100/// ladder (mirrors [`ENV_LLM_API_KEY`]).
4101pub const ENV_EMBED_API_KEY: &str = "AI_MEMORY_EMBED_API_KEY";
4102/// #38 — env override for the embedding backfill batch size
4103/// (`[embeddings].backfill_batch`). Hoisted from a raw literal in the
4104/// resolver per the no-hardcoded-literals discipline (#1598).
4105pub const ENV_EMBED_BACKFILL_BATCH: &str = "AI_MEMORY_EMBED_BACKFILL_BATCH";
4106
4107/// Compiled-default embedding model id (the v0.7.0 autonomous-tier
4108/// nomic default), shared by the resolver and its precedence tests.
4109pub(crate) const DEFAULT_EMBED_MODEL: &str = "nomic-embed-text-v1.5";
4110/// Compiled-default embedding backfill batch size.
4111pub(crate) const DEFAULT_EMBED_BACKFILL_BATCH: u32 = 100;
4112
4113/// v0.7.x (issue #1168) — bundle the three model-resolver outputs into
4114/// a single triple consumed by the capabilities surface. Lets callers
4115/// thread ONE struct through `handle_capabilities_with_conn` /
4116/// `handle_capabilities_with_conn_v3` / `build_capabilities_overlay`
4117/// instead of three independent borrows, and makes the contract loud:
4118/// `memory_capabilities.models.*` reflects the operator-resolved
4119/// configuration, NEVER the compiled tier preset.
4120///
4121/// **Production constructor:** [`AppConfig::resolve_models`].
4122/// **Test / back-compat constructor:** [`ResolvedModels::from_tier_preset`].
4123#[derive(Debug, Clone, PartialEq, Eq)]
4124pub struct ResolvedModels {
4125    /// Resolved LLM configuration (`AppConfig::resolve_llm`).
4126    pub llm: ResolvedLlm,
4127    /// Resolved embedder configuration (`AppConfig::resolve_embeddings`).
4128    pub embeddings: ResolvedEmbeddings,
4129    /// Resolved reranker configuration (`AppConfig::resolve_reranker`).
4130    pub reranker: ResolvedReranker,
4131}
4132
4133/// Compiled-default `ResolvedModels` triple. Equivalent to running
4134/// the resolvers against an [`AppConfig::default`] — Ollama backend,
4135/// no operator overrides, no API key, reranker disabled. Convenient
4136/// for test scaffolds that need a `ResolvedModels` value but don't
4137/// care about its contents.
4138impl Default for ResolvedModels {
4139    fn default() -> Self {
4140        Self {
4141            llm: ResolvedLlm {
4142                backend: "ollama".to_string(),
4143                model: String::new(),
4144                base_url: "http://localhost:11434".to_string(),
4145                api_key: None,
4146                api_key_source: KeySource::None,
4147                source: ConfigSource::CompiledDefault,
4148            },
4149            embeddings: ResolvedEmbeddings {
4150                backend: "ollama".to_string(),
4151                url: "http://localhost:11434".to_string(),
4152                model: String::new(),
4153                backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
4154                embedding_dim: None,
4155                requested_dim: None,
4156                api_key: None,
4157                key_source: KeySource::None,
4158                source: ConfigSource::CompiledDefault,
4159            },
4160            reranker: ResolvedReranker {
4161                enabled: false,
4162                model: "ms-marco-MiniLM-L-6-v2".to_string(),
4163                max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
4164                source: ConfigSource::CompiledDefault,
4165            },
4166        }
4167    }
4168}
4169
4170impl ResolvedModels {
4171    /// Back-compat constructor: synthesise a `ResolvedModels` triple
4172    /// from the compiled [`TierConfig`] preset alone.
4173    ///
4174    /// Yields the same [`CapabilityModels`] byte-for-byte that the
4175    /// pre-#1168 `TierConfig::capabilities()` produced, so legacy
4176    /// callers + tests that scaffold a `TierConfig` in isolation (no
4177    /// `AppConfig` available) continue to assert their original
4178    /// strings. The synthesised triple carries
4179    /// [`ConfigSource::CompiledDefault`] on every leaf so observers can
4180    /// distinguish a back-compat scaffold from an operator-resolved
4181    /// production triple.
4182    ///
4183    /// **Production paths** that have access to the operator
4184    /// [`AppConfig`] MUST use [`AppConfig::resolve_models`] instead.
4185    /// Using this helper in a production wrapper re-introduces the
4186    /// #1168 drift (the capabilities surface would report the tier
4187    /// preset instead of the operator-configured backend / model).
4188    #[must_use]
4189    pub fn from_tier_preset(tier: &TierConfig) -> Self {
4190        Self {
4191            llm: ResolvedLlm {
4192                backend: "ollama".to_string(),
4193                model: tier.llm_model.clone().unwrap_or_default(),
4194                base_url: "http://localhost:11434".to_string(),
4195                api_key: None,
4196                api_key_source: KeySource::None,
4197                source: ConfigSource::CompiledDefault,
4198            },
4199            embeddings: ResolvedEmbeddings {
4200                backend: "ollama".to_string(),
4201                url: "http://localhost:11434".to_string(),
4202                model: tier
4203                    .embedding_model
4204                    .map(|m| m.hf_model_id().to_string())
4205                    .unwrap_or_default(),
4206                backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
4207                // v0.7.x (#1169) — back-compat constructor: source the
4208                // dim from the tier-preset enum directly so the
4209                // ResolvedModels::from_tier_preset path matches the
4210                // pre-#1169 capabilities byte-shape (the test invariant
4211                // pinned by tests/issue_1168_*::from_tier_preset_*).
4212                embedding_dim: tier.embedding_model.map(|m| m.dim() as u32),
4213                requested_dim: None,
4214                api_key: None,
4215                key_source: KeySource::None,
4216                source: ConfigSource::CompiledDefault,
4217            },
4218            reranker: ResolvedReranker {
4219                enabled: tier.cross_encoder,
4220                // Back-compat: the pre-#1168 capabilities surface emitted
4221                // the full `cross-encoder/...` HF org-prefixed string when
4222                // the tier-preset enabled the cross-encoder. Preserve
4223                // that here so legacy assertions stay byte-equal.
4224                model: "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string(),
4225                max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
4226                source: ConfigSource::CompiledDefault,
4227            },
4228        }
4229    }
4230}
4231
4232/// v0.7.0 (issue #518) — `[agents]` top-level block. Today only carries
4233/// the `defaults` sub-block (`[agents.defaults.recall_scope]`); future
4234/// agent-scoped knobs (per-agent quota overrides, per-agent autonomy
4235/// hook policy) can stack here without bloating the top-level
4236/// `AppConfig` surface.
4237///
4238/// Wire format:
4239/// ```toml
4240/// [agents.defaults.recall_scope]
4241/// namespaces = ["projects/atlas"]
4242/// since = "24h"
4243/// tier = "long"
4244/// limit = 50
4245/// ```
4246#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4247pub struct AgentsConfig {
4248    /// `[agents.defaults]` sub-block. `None` keeps recall semantics
4249    /// exactly as v0.6.x — every cross-session `memory_recall` requires
4250    /// explicit filters. `Some` enables `session_default=true` callers
4251    /// to splice these defaults into their request before storage
4252    /// dispatch.
4253    #[serde(default)]
4254    pub defaults: Option<AgentDefaults>,
4255}
4256
4257/// v0.7.0 (issue #518) — `[agents.defaults]` sub-block. Today exposes a
4258/// single field: `recall_scope`. Future expansion (per-call timeouts,
4259/// per-call tag filters, …) lives here.
4260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4261pub struct AgentDefaults {
4262    /// `[agents.defaults.recall_scope]` — default filter set spliced
4263    /// into recall calls that pass `session_default=true` and omit
4264    /// individual filter fields. See [`RecallScope`] for field
4265    /// semantics. `None` is equivalent to "no defaults configured".
4266    #[serde(default)]
4267    pub recall_scope: Option<RecallScope>,
4268}
4269
4270/// v0.7.0 (issue #518) — operator-configured recall defaults. Each
4271/// field is optional; when present and the inbound recall request
4272/// omits the corresponding axis AND passes `session_default=true`, the
4273/// handler splices in the configured value before dispatching to the
4274/// storage layer.
4275///
4276/// Resolution: **explicit request args > recall_scope defaults >
4277/// compiled defaults**. The splice never overrides an explicit filter
4278/// — operators can always narrow the result set further at call time.
4279///
4280/// Wire format:
4281/// ```toml
4282/// [agents.defaults.recall_scope]
4283/// namespaces = ["projects/atlas"]   # default namespace filter
4284/// since = "24h"                     # duration → since = now() - 24h
4285/// tier = "long"                     # "short" / "mid" / "long"
4286/// limit = 50                        # default cap
4287/// ```
4288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4289pub struct RecallScope {
4290    /// Default namespace filter applied when the request omits its
4291    /// own `namespace` field. The current recall handlers accept a
4292    /// single namespace per call; when multiple namespaces are
4293    /// configured we apply the first one. (The list form is future-
4294    /// compatible with a planned multi-namespace recall surface.)
4295    #[serde(default)]
4296    pub namespaces: Option<Vec<String>>,
4297    /// Default time-window applied when the request omits `since`.
4298    /// Expressed as a duration string: `"24h"`, `"7d"`, `"30m"`, … See
4299    /// [`parse_duration_string`] for the parser. The handler resolves
4300    /// it to `now() - duration` at request time and passes the
4301    /// resulting RFC3339 timestamp through the existing `since`
4302    /// filter — no new SQL path.
4303    #[serde(default)]
4304    pub since: Option<String>,
4305    /// Default tier filter applied when the request omits its own
4306    /// `tier`. Accepted values: `"short"` / `"mid"` / `"long"`. The
4307    /// sqlite recall handlers do not currently expose a tier
4308    /// parameter, so this knob is applied on the postgres SAL path
4309    /// (which carries a `Filter.tier`) and stored on the request
4310    /// envelope for forward-compatibility on sqlite (no observable
4311    /// behaviour change there).
4312    #[serde(default)]
4313    pub tier: Option<String>,
4314    /// Default recall limit applied when the request omits its own
4315    /// `limit`. The handler still clamps to the per-tool maximum
4316    /// (50) after applying this default, so an oversized value here
4317    /// degrades gracefully.
4318    #[serde(default)]
4319    pub limit: Option<u32>,
4320}
4321
4322/// v0.7.0 Cluster G (#767) — `[confidence]` config block. Carries the
4323/// retention window for `confidence_shadow_observations` consumed by
4324/// the periodic GC sweep wired into `daemon_runtime::spawn_gc_loop`.
4325///
4326/// Wire format:
4327/// ```toml
4328/// [confidence]
4329/// shadow_retention_days = 30
4330/// ```
4331///
4332/// `None` → the compiled default
4333/// ([`crate::confidence::shadow::DEFAULT_SHADOW_RETENTION_DAYS`] = 30)
4334/// applies. Set to `0` or a negative value to disable the sweep
4335/// (matches the audit-honest "do-nothing-on-zero" convention used by
4336/// `archive_max_days`).
4337#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
4338pub struct ConfidenceConfig {
4339    /// Retention window (in days) for shadow-mode observation rows.
4340    /// Rows whose `observed_at` is older than `now - N days` are
4341    /// deleted by the GC sweep. `None` → compiled default of 30 days.
4342    /// `Some(0)` or `Some(<0)` → sweep is a no-op (operator opt-out
4343    /// for compliance / forensic-retention scenarios).
4344    pub shadow_retention_days: Option<i64>,
4345}
4346
4347impl ConfidenceConfig {
4348    /// Effective retention window, honoring the compiled default when
4349    /// the config block is absent or `shadow_retention_days` is unset.
4350    #[must_use]
4351    pub fn effective_shadow_retention_days(&self) -> i64 {
4352        self.shadow_retention_days
4353            .unwrap_or(crate::confidence::shadow::DEFAULT_SHADOW_RETENTION_DAYS)
4354    }
4355}
4356
4357/// v0.7.0 H7 (round-2) — compiled default per-request HTTP timeout.
4358/// Applied when `AppConfig::request_timeout_secs` is `None`.
4359pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
4360
4361/// v0.7.0 H8 (round-2) — compiled default per-LLM-call timeout.
4362/// Applied when `AppConfig::llm_call_timeout_secs` is `None`.
4363pub const DEFAULT_LLM_CALL_TIMEOUT_SECS: u64 = 30;
4364
4365// ---------------------------------------------------------------------------
4366// Hooks / subscription HMAC (K7)
4367// ---------------------------------------------------------------------------
4368
4369/// `[hooks]` config block. v0.7.0 K7 — operator-facing knobs for the
4370/// outgoing-webhook surface.
4371///
4372/// Wire format:
4373/// ```toml
4374/// [hooks.subscription]
4375/// hmac_secret = "<plaintext-secret>"
4376/// ```
4377///
4378/// When `hmac_secret` is set, EVERY outbound webhook payload is signed
4379/// with `HMAC-SHA256(hmac_secret, "<timestamp>.<body>")` and the hex
4380/// digest is sent as the `X-AI-Memory-Signature: sha256=<hex>` header.
4381/// The override applies even to subscriptions that did not register a
4382/// per-subscription secret. When both are set, the per-subscription
4383/// secret wins (subscription-scoped trust beats server-scoped trust).
4384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4385pub struct HooksConfig {
4386    /// `[hooks.subscription]` sub-block. Optional — when omitted, no
4387    /// server-wide HMAC override applies.
4388    pub subscription: Option<HooksSubscriptionConfig>,
4389}
4390
4391/// `[hooks.subscription]` sub-block. K7 ships one knob today
4392/// (`hmac_secret`); future K-track work may add per-event opt-out
4393/// filters or alternate signing algorithms.
4394///
4395/// #1262 — `Debug` is implemented manually to redact `hmac_secret` so
4396/// accidental `{:?}` prints never leak the signing key. #1258 — the
4397/// manual `Drop` impl zeroizes the secret on scope exit.
4398#[derive(Clone, Default, Serialize, Deserialize)]
4399pub struct HooksSubscriptionConfig {
4400    /// Server-wide HMAC secret. Plaintext on disk — operators are
4401    /// expected to chmod 600 the config file (same posture as the
4402    /// existing `api_key` field).
4403    ///
4404    /// #1262 — `skip_serializing` blocks the secret from being echoed
4405    /// through any `serde_json::to_string(&HooksSubscriptionConfig)`
4406    /// path.
4407    #[serde(default, skip_serializing)]
4408    pub hmac_secret: Option<String>,
4409}
4410
4411impl std::fmt::Debug for HooksSubscriptionConfig {
4412    /// #1262 — redact `hmac_secret` to `<redacted>` when present.
4413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4414        f.debug_struct("HooksSubscriptionConfig")
4415            .field(
4416                "hmac_secret",
4417                &self
4418                    .hmac_secret
4419                    .as_ref()
4420                    .map(|_| crate::REDACTED_PLACEHOLDER),
4421            )
4422            .finish()
4423    }
4424}
4425
4426impl HooksSubscriptionConfig {
4427    /// #1258 — zeroize the `hmac_secret` buffer in place. Idempotent.
4428    /// The `Drop` impl below delegates here so the helper is the
4429    /// single source of truth for the zero-on-secret-loss contract.
4430    /// Tests probe the buffer via this entry point so they observe
4431    /// the post-zeroize state of a still-live allocation (probing
4432    /// after the owning value is dropped is UB — the allocator's
4433    /// free-list bookkeeping stamps the first 8-16 bytes of the
4434    /// just-freed slot and that's not a `zeroize` defect; see #1321).
4435    pub fn zeroize_secrets(&mut self) {
4436        if let Some(secret) = self.hmac_secret.as_mut() {
4437            use zeroize::Zeroize;
4438            secret.zeroize();
4439        }
4440    }
4441}
4442
4443impl Drop for HooksSubscriptionConfig {
4444    /// #1258 — zeroize `hmac_secret` on scope exit. Delegates to
4445    /// [`HooksSubscriptionConfig::zeroize_secrets`].
4446    fn drop(&mut self) {
4447        self.zeroize_secrets();
4448    }
4449}
4450
4451/// v0.7.0 H5 (round-2) — `[verify]` config block. Operator-facing
4452/// knobs for `POST /api/v1/links/verify`. Today exposes one knob:
4453/// `require_nonce` (default `false`).
4454///
4455/// Wire format:
4456/// ```toml
4457/// [verify]
4458/// require_nonce = true     # strict mode — every verify request
4459///                          # must carry verification_nonce
4460/// ```
4461///
4462/// When `require_nonce = false` (the default), the handler logs a
4463/// deprecation WARN when a request omits `verification_nonce` but
4464/// still allows it through. When `true`, missing nonces are rejected
4465/// with 409 Conflict and the operator's audit trail receives every
4466/// attempted reuse.
4467#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4468pub struct VerifyConfig {
4469    /// When `true`, `POST /api/v1/links/verify` requires every
4470    /// request body to include a `verification_nonce` field. Missing
4471    /// or empty nonces produce a 400 Bad Request. Already-seen
4472    /// `(link_id, signature, nonce)` tuples produce a 409 Conflict
4473    /// with `{"error":"verification replay detected"}`. Default `false`
4474    /// preserves the v0.6.x verify-anytime semantics; operators
4475    /// opting into the H5 replay-protection guarantee set this to
4476    /// `true` after their clients have been updated to emit nonces.
4477    #[serde(default)]
4478    pub require_nonce: bool,
4479}
4480
4481/// v0.7.0 H11 (#628 blocker) — `[subscriptions]` block. Operator
4482/// knobs for the outgoing-webhook surface that are NOT specific to
4483/// HMAC signing (which lives under `[hooks.subscription]`).
4484///
4485/// Wire format:
4486/// ```toml
4487/// [subscriptions]
4488/// allow_loopback_webhooks = true   # default false; opt-in for testing
4489/// ```
4490///
4491/// When unset (or false), the SSRF guard rejects webhook URLs that
4492/// resolve to loopback addresses (`127.0.0.0/8`, `localhost`, `::1`).
4493/// Loopback hosts are reachable from the daemon process itself, so
4494/// permitting them by default exposes any locally-bound service
4495/// (database, internal admin sockets) to authenticated SSRF.
4496#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4497pub struct SubscriptionsConfig {
4498    /// Re-enable loopback webhook URLs. Default `false` (loopback
4499    /// rejected). Operators who need to point a webhook at a local
4500    /// listener (CI, dev) set this to `true` explicitly.
4501    #[serde(default)]
4502    pub allow_loopback_webhooks: bool,
4503}
4504
4505impl AppConfig {
4506    /// v0.7.0 K7 — resolved server-wide webhook HMAC secret. `None`
4507    /// means no server-wide override (per-subscription secrets still
4508    /// apply via the legacy code path).
4509    #[must_use]
4510    pub fn effective_hooks_hmac_secret(&self) -> Option<String> {
4511        self.hooks
4512            .as_ref()
4513            .and_then(|h| h.subscription.as_ref())
4514            .and_then(|s| s.hmac_secret.clone())
4515    }
4516
4517    /// v0.7.0 (issue #518) — resolved `[agents.defaults.recall_scope]`
4518    /// block. Returns `Some(&scope)` when configured, `None` otherwise.
4519    /// Consumed by the recall handlers (sqlite + postgres SAL branches,
4520    /// MCP `handle_recall`, CLI `cmd_recall`) to splice defaults into
4521    /// requests that pass `session_default=true` and omit one or more
4522    /// filter fields.
4523    #[must_use]
4524    pub fn effective_recall_scope(&self) -> Option<&RecallScope> {
4525        self.agents
4526            .as_ref()
4527            .and_then(|a| a.defaults.as_ref())
4528            .and_then(|d| d.recall_scope.as_ref())
4529    }
4530
4531    /// v0.7.0 H11 (#628 blocker) — resolved loopback-webhook opt-in
4532    /// flag. Defaults to `false` (loopback rejected — closes the
4533    /// authenticated SSRF gadget against local services). Operators
4534    /// who need loopback for testing set
4535    /// `[subscriptions] allow_loopback_webhooks = true`.
4536    ///
4537    /// Resolution order (mirrors `effective_permissions_mode`):
4538    /// 1. `AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS` env var (`1` / `true` —
4539    ///    case-insensitive). Lets the integration suite — which
4540    ///    sets `AI_MEMORY_NO_CONFIG=1` and therefore cannot use
4541    ///    `[subscriptions]` from `config.toml` — bind wiremock at
4542    ///    `127.0.0.1:0` and drive webhooks through it without
4543    ///    touching the production default.
4544    /// 2. `[subscriptions].allow_loopback_webhooks` from `config.toml`.
4545    /// 3. Compiled default (`false` — loopback rejected).
4546    #[must_use]
4547    pub fn effective_allow_loopback_webhooks(&self) -> bool {
4548        if let Ok(raw) = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS") {
4549            match raw.to_ascii_lowercase().as_str() {
4550                "1" | "true" | "yes" | "on" => return true,
4551                "0" | "false" | "no" | "off" | "" => return false,
4552                other => {
4553                    eprintln!(
4554                        "ai-memory: AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS={other:?} is not a valid \
4555                         boolean (expected 1/true/yes/on or 0/false/no/off); falling back to \
4556                         config.toml"
4557                    );
4558                }
4559            }
4560        }
4561        self.subscriptions
4562            .as_ref()
4563            .is_some_and(|s| s.allow_loopback_webhooks)
4564    }
4565}
4566
4567// ---------------------------------------------------------------------------
4568// Process-wide handle for the K7 server-wide HMAC override.
4569// Mirrors the `ACTIVE_PERMISSIONS_MODE` pattern: set once at boot,
4570// read by `subscriptions::dispatch_event_with_details` without an
4571// API churn through every callsite.
4572//
4573// v0.7.x (issue #1174 follow-up #1192) — storage moved to
4574// `RuntimeContext::hooks_hmac_secret` so the HTTP daemon, the MCP
4575// stdio binary, and the CLI all share one source of truth. The
4576// accessors below delegate to the process-wide singleton; the wire
4577// semantics + the K7 integration-test fixture (which flips the value
4578// mid-process) are byte-equivalent.
4579// ---------------------------------------------------------------------------
4580
4581/// v0.7.0 K7 — set the process-wide webhook HMAC override. Called from
4582/// `main`/daemon bootstrap with the value from
4583/// `[hooks.subscription] hmac_secret`. Last writer wins — this is
4584/// production-safe because boot only invokes it once; tests use the
4585/// same setter to flip mid-process.
4586///
4587/// v0.7.x (issue #1192) — delegates to
4588/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4589/// lives on `RuntimeContext::hooks_hmac_secret`.
4590pub fn set_active_hooks_hmac_secret(secret: Option<String>) {
4591    if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
4592        .hooks_hmac_secret
4593        .write()
4594    {
4595        *w = secret;
4596    }
4597}
4598
4599/// v0.7.0 K7 — read the process-wide webhook HMAC override. Returns
4600/// `None` when unset (the K6-and-earlier behaviour: only
4601/// per-subscription secrets sign outgoing payloads).
4602///
4603/// v0.7.x (issue #1192) — delegates to
4604/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4605/// lives on `RuntimeContext::hooks_hmac_secret`.
4606#[must_use]
4607pub fn active_hooks_hmac_secret() -> Option<String> {
4608    crate::runtime_context::RuntimeContext::global()
4609        .hooks_hmac_secret
4610        .read()
4611        .ok()
4612        .and_then(|g| g.clone())
4613}
4614
4615// ---------------------------------------------------------------------------
4616// I1 cap (#628 agent-3 follow-up) — process-wide transcript decompression cap
4617// ---------------------------------------------------------------------------
4618//
4619// `transcripts::fetch` consults this getter to decide the maximum
4620// number of bytes a single transcript may decompress to. Operators
4621// who legitimately store >16 MiB transcripts raise the cap explicitly
4622// via `[transcripts] max_decompressed_bytes = …`; default-on uses the
4623// compiled `MAX_DECOMPRESSED_BYTES` constant. The cap is per-call;
4624// concurrent fetches consume up to N × this value of transient memory.
4625//
4626// v0.7.x (issue #1174 follow-up #1192) — storage moved to
4627// `RuntimeContext::max_decompressed_bytes`. The accessors below
4628// delegate; the per-call cap semantics are byte-equivalent.
4629
4630/// Set the process-wide decompression cap. Boot reads
4631/// `[transcripts] max_decompressed_bytes` and calls this; tests flip
4632/// mid-process to exercise both branches.
4633///
4634/// v0.7.x (issue #1192) — delegates to
4635/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4636/// lives on `RuntimeContext::max_decompressed_bytes`.
4637pub fn set_active_max_decompressed_bytes(cap: Option<usize>) {
4638    if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
4639        .max_decompressed_bytes
4640        .write()
4641    {
4642        *w = cap;
4643    }
4644}
4645
4646/// Read the process-wide decompression cap, falling back to the
4647/// compiled default when unset.
4648///
4649/// v0.7.x (issue #1192) — delegates to
4650/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4651/// lives on `RuntimeContext::max_decompressed_bytes`.
4652#[must_use]
4653pub fn active_max_decompressed_bytes() -> usize {
4654    crate::runtime_context::RuntimeContext::global()
4655        .max_decompressed_bytes
4656        .read()
4657        .ok()
4658        .and_then(|g| *g)
4659        .unwrap_or(crate::transcripts::MAX_DECOMPRESSED_BYTES)
4660}
4661
4662// ---------------------------------------------------------------------------
4663// H11 — process-wide handle for the loopback-webhook opt-in
4664// ---------------------------------------------------------------------------
4665//
4666// `validate_url` in `subscriptions.rs` consults this handle to decide
4667// whether to accept loopback webhook destinations. Default-OFF closes
4668// the SSRF gadget; the boot code in `main` / daemon reads
4669// `[subscriptions] allow_loopback_webhooks` and sets the flag here.
4670
4671// Default-OFF in production builds so the SSRF guard rejects loopback
4672// without explicit opt-in. Defaults to `true` under `cfg(test)` so
4673// the existing test surface (which binds wiremock to `127.0.0.1:0`
4674// and drives validate_url/validate_url_dns through real loopback
4675// URLs) passes without 16-test fan-out modifications. The H11
4676// default-OFF behaviour is independently asserted via the
4677// `validate_url_with` / `validate_url_dns_check_addrs` inner helpers
4678// in `subscriptions.rs`, so flipping the test-build default here
4679// does NOT relax the H11 ship-gate test coverage.
4680static ALLOW_LOOPBACK_WEBHOOKS: std::sync::atomic::AtomicBool =
4681    std::sync::atomic::AtomicBool::new(cfg!(test));
4682
4683/// v0.7.0 H11 — set the process-wide loopback-webhook opt-in. Called
4684/// from boot with the value of `[subscriptions] allow_loopback_webhooks`.
4685/// Defaults to `false` (loopback rejected).
4686pub fn set_allow_loopback_webhooks(allow: bool) {
4687    ALLOW_LOOPBACK_WEBHOOKS.store(allow, std::sync::atomic::Ordering::SeqCst);
4688}
4689
4690/// v0.7.0 H11 — read the process-wide loopback-webhook opt-in.
4691/// Returns `false` when unset (the safe default — loopback URLs are
4692/// rejected by the SSRF guard).
4693#[must_use]
4694pub fn allow_loopback_webhooks() -> bool {
4695    ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst)
4696}
4697
4698// ---------------------------------------------------------------------------
4699// Permissions / governance gate (K3)
4700// ---------------------------------------------------------------------------
4701
4702/// Enforcement posture consulted by [`crate::db::enforce_governance`].
4703///
4704/// v0.7.0 K3 — closes the v0.6.3.1 honest-Capabilities-v2 disclosure
4705/// that `permissions.mode = "advisory"` was advertised but the gate
4706/// itself returned `Deny` / `Pending` regardless. The gate now actually
4707/// honors this knob.
4708///
4709/// Wire format on `config.toml`:
4710///
4711/// ```toml
4712/// [permissions]
4713/// mode = "advisory"   # or "enforce" / "off"
4714/// ```
4715#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4716#[serde(rename_all = "lowercase")]
4717pub enum PermissionsMode {
4718    /// Block on policy violation. `Deny`/`Pending` decisions returned
4719    /// to the caller as-is. The strict, audit-ready posture.
4720    Enforce,
4721    /// Log a warning and allow the action. Governance metadata is
4722    /// recorded but does not block writes. Default for v0.7.0 to
4723    /// preserve the v0.6.x posture for upgrading operators.
4724    Advisory,
4725    /// Skip the gate entirely. No policy resolution, no log, no
4726    /// `pending_actions` row. Useful for benchmarking and temporary
4727    /// freeze-thaw incident response.
4728    Off,
4729}
4730
4731impl Default for PermissionsMode {
4732    fn default() -> Self {
4733        Self::Advisory
4734    }
4735}
4736
4737impl PermissionsMode {
4738    /// Lowercase wire string for capabilities + doctor surfaces.
4739    #[must_use]
4740    pub fn as_str(self) -> &'static str {
4741        match self {
4742            Self::Enforce => "enforce",
4743            Self::Advisory => "advisory",
4744            Self::Off => "off",
4745        }
4746    }
4747}
4748
4749/// `[permissions]` block in `config.toml`. Carries the gate's
4750/// enforcement posture and (v0.7.0 K9) the declarative rule list
4751/// the unified [`crate::permissions::Permissions::evaluate`]
4752/// pipeline consults before mode + hook fall-through.
4753///
4754/// Wire format (rules — K9):
4755///
4756/// ```toml
4757/// [permissions]
4758/// mode = "enforce"
4759///
4760/// [[permissions.rules]]
4761/// namespace_pattern = "secrets/*"
4762/// op               = "memory_store"
4763/// agent_pattern    = "ai:*"
4764/// decision         = "deny"
4765/// reason           = "ai agents may not write to secrets"
4766/// ```
4767///
4768/// Rules are deny-first and longest-pattern-wins; see
4769/// [`crate::permissions`] module docs for the full combination
4770/// rule.
4771#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4772pub struct PermissionsConfig {
4773    /// Enforcement mode. `None` when the operator declared a
4774    /// `[permissions]` block but omitted `mode = ` — this is the
4775    /// "partial config" case that B4 (S5-M3) closes: such a block
4776    /// MUST NOT silently fall back to the serde-derived
4777    /// `PermissionsMode::default` (`advisory`), because the v0.7.0
4778    /// secure default is `enforce`. The
4779    /// [`AppConfig::effective_permissions_mode`] resolver maps
4780    /// `Some(cfg { mode: None })` to the secure default + a
4781    /// migration warning, so an operator who half-typed
4782    /// `[permissions]` and forgot the mode line still ships
4783    /// `enforce`, not the v0.6.x advisory posture.
4784    ///
4785    /// Serializes as omitted when `None` so a round-tripped config
4786    /// without an explicit `mode` keeps the partial-config shape
4787    /// for the next loader.
4788    #[serde(default, skip_serializing_if = "Option::is_none")]
4789    pub mode: Option<PermissionsMode>,
4790    /// v0.7.0 K9 — declarative permission rules. Each entry is a
4791    /// `(namespace_pattern, op, agent_pattern, decision)` tuple
4792    /// consulted by [`crate::permissions::Permissions::evaluate`]
4793    /// before the mode default falls through. Defaults to empty
4794    /// (no declarative rules — pre-K9 behaviour: mode + hooks +
4795    /// existing governance gate decide everything).
4796    #[serde(default)]
4797    pub rules: Vec<crate::permissions::PermissionRule>,
4798}
4799
4800// ---------------------------------------------------------------------------
4801// Process-wide permissions-mode handle (K3)
4802// ---------------------------------------------------------------------------
4803//
4804// The gate (`db::enforce_governance`) needs to consult the active mode
4805// at decision time but lives in the `db` module, which has no handle on
4806// `AppConfig`. We hold the active mode in a single `RwLock<Option<…>>`
4807// set by `main` (and the daemon runtime) so the gate can read the mode
4808// without an API churn through every callsite. When the lock is unset
4809// — the case for unit and integration tests that drive
4810// `db::enforce_governance` directly without booting the daemon — the
4811// gate defaults to [`PermissionsMode::Advisory`] (the v0.7.0 K3
4812// secure-but-non-blocking posture). Tests opt into `Enforce` via the
4813// `set_active_permissions_mode` setter or the
4814// `override_active_permissions_mode_for_test` alias.
4815//
4816// **#1174 pm-v3.1 PR7 (this commit)**: collapsed the previous
4817// dual-source-of-truth (a `OnceLock<PermissionsMode>` for production +
4818// an `AtomicU8` test-only override that secretly took precedence over
4819// it) into a single `RwLock<Option<PermissionsMode>>`. The previous
4820// `OnceLock` shape blocked legitimate runtime reload paths — a SIGHUP
4821// handler that wanted to re-resolve `[permissions].mode` from
4822// `config.toml` and call `set_active_permissions_mode` again would
4823// silently no-op, leaving the gate on the boot-time value while every
4824// other resolver caught the new value. The new shape supports
4825// last-writer-wins so a future SIGHUP / `ai-memory reload` surface
4826// can refresh the mode without restart. The test-override semantics
4827// are preserved: tests still hold the
4828// [`lock_permissions_mode_for_test`] guard around their mutations and
4829// the public setter / overrider signatures are unchanged.
4830
4831static ACTIVE_PERMISSIONS_MODE: std::sync::RwLock<Option<PermissionsMode>> =
4832    std::sync::RwLock::new(None);
4833
4834/// Set the process-wide active [`PermissionsMode`]. Called from `main`
4835/// (CLI) and the daemon bootstrap path with the value resolved from
4836/// `[permissions].mode` in `config.toml`. Last-writer-wins so a future
4837/// SIGHUP / `ai-memory reload` surface can refresh the mode without
4838/// restart (#1174 PR7); the previous `OnceLock` shape made repeat
4839/// callers silently no-op.
4840pub fn set_active_permissions_mode(mode: PermissionsMode) {
4841    if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
4842        *w = Some(mode);
4843    }
4844}
4845
4846/// The pre-initialization fallback mode for [`active_permissions_mode`].
4847///
4848/// Every production entry point (CLI, MCP, HTTP `serve`) resolves the
4849/// real mode via [`AppConfig::effective_permissions_mode`] — whose
4850/// v0.7.0 secure default is [`PermissionsMode::Enforce`] — and installs
4851/// it via [`set_active_permissions_mode`] during boot, BEFORE any write
4852/// can reach the governance gate. This constant is therefore only ever
4853/// observed when the gate is consulted before boot ran (a library
4854/// embedding that never called the setter, or a unit test that does not
4855/// opt into a specific mode). It is held at `Advisory` to preserve the
4856/// historical pre-init behaviour the test suite relies on; the
4857/// [`active_permissions_mode`] reader emits a one-shot WARN when it has
4858/// to fall back to this value so the uninitialized-gate condition is
4859/// observable rather than silent.
4860const UNINITIALIZED_PERMISSIONS_MODE_FALLBACK: PermissionsMode = PermissionsMode::Advisory;
4861
4862/// Read the process-wide active [`PermissionsMode`] installed at boot by
4863/// [`set_active_permissions_mode`] (sourced from
4864/// [`AppConfig::effective_permissions_mode`], whose v0.7.0 secure
4865/// default is [`PermissionsMode::Enforce`]).
4866///
4867/// When the slot is unset — i.e. boot has NOT run — this returns
4868/// [`UNINITIALIZED_PERMISSIONS_MODE_FALLBACK`] and emits a one-shot
4869/// operator-visible WARN, because consulting the governance gate before
4870/// the mode is installed is a defense-in-depth gap: the gate would run
4871/// against the pre-init fallback rather than the operator's resolved
4872/// mode. In production this path is unreachable (boot always installs
4873/// the mode first); the WARN exists to surface a regression if that
4874/// ordering ever breaks.
4875///
4876/// Test note: the K1 ship-gate matrix asserts `Pending`/`Deny`
4877/// outcomes from `db::enforce_governance` and therefore opts into
4878/// `Enforce` via [`set_active_permissions_mode`] at the start of each
4879/// scenario.
4880#[must_use]
4881pub fn active_permissions_mode() -> PermissionsMode {
4882    match ACTIVE_PERMISSIONS_MODE.read().ok().and_then(|g| *g) {
4883        Some(mode) => mode,
4884        None => {
4885            static UNINIT_GATE_WARN_ONCE: std::sync::Once = std::sync::Once::new();
4886            UNINIT_GATE_WARN_ONCE.call_once(|| {
4887                tracing::warn!(
4888                    target: crate::governance::GOVERNANCE_TRACE_TARGET,
4889                    fallback = UNINITIALIZED_PERMISSIONS_MODE_FALLBACK.as_str(),
4890                    "permissions mode consulted before boot installed it; using the \
4891                     pre-init fallback. Production entry points install the resolved \
4892                     mode (secure default: enforce) during boot — if you see this in \
4893                     a running daemon, the boot ordering regressed."
4894                );
4895            });
4896            UNINITIALIZED_PERMISSIONS_MODE_FALLBACK
4897        }
4898    }
4899}
4900
4901/// Test-only override of the active mode. Production code MUST use
4902/// [`set_active_permissions_mode`]; this helper exists so the K3 test
4903/// matrix can flip mode mid-test without spinning up a fresh process.
4904///
4905/// **#1174 PR7**: with the dual-source-of-truth collapse the override
4906/// is now a thin alias around [`set_active_permissions_mode`]. The
4907/// two functions are wire-equivalent at every callsite. The alias is
4908/// kept (rather than renaming all test callers in one pass) because
4909/// the `_for_test` suffix at every callsite documents the intent —
4910/// "this is a test poking the global gate" — better than an
4911/// unsuffixed setter would.
4912#[doc(hidden)]
4913pub fn override_active_permissions_mode_for_test(mode: PermissionsMode) {
4914    set_active_permissions_mode(mode);
4915}
4916
4917/// Test-only: clear any test-override so subsequent tests start from
4918/// the unset state (the [`PermissionsMode::Advisory`] default).
4919///
4920/// **#1174 PR7**: previously this cleared the `OVERRIDE_PERMISSIONS_MODE`
4921/// atomic without touching the production-side `OnceLock`, which let
4922/// a test that called the production setter once leak its value into
4923/// the next test. With the single-source-of-truth collapse, clearing
4924/// resets the lone slot — subsequent reads see `Advisory` until the
4925/// next setter call, which is the documented contract.
4926#[doc(hidden)]
4927pub fn clear_permissions_mode_override_for_test() {
4928    if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
4929        *w = None;
4930    }
4931}
4932
4933/// Test-only: acquire the global gate-mode serialization lock.
4934///
4935/// The active [`PermissionsMode`] lives in a process-wide atomic so
4936/// the gate at `db::enforce_governance` can read it without an API
4937/// churn through every callsite. Multiple lib tests flip the mode
4938/// (the K3 mode-matrix file, the CLI / HTTP gate scenarios, the
4939/// capabilities zero-state round-trip) and `cargo test --lib` runs
4940/// them in parallel by default. Each scenario MUST hold this guard
4941/// for its duration so two scenarios cannot race the atomic. The
4942/// returned guard poisons-OK so one panicking scenario does not
4943/// chain-fail the rest.
4944#[doc(hidden)]
4945#[must_use]
4946pub fn lock_permissions_mode_for_test() -> std::sync::MutexGuard<'static, ()> {
4947    use std::sync::Mutex;
4948    static GATE_LOCK: Mutex<()> = Mutex::new(());
4949    GATE_LOCK
4950        .lock()
4951        .unwrap_or_else(std::sync::PoisonError::into_inner)
4952}
4953
4954// ---------------------------------------------------------------------------
4955// Decision counters per mode (K3 — surfaced by doctor + capabilities)
4956// ---------------------------------------------------------------------------
4957
4958use std::sync::atomic::{AtomicU64, Ordering};
4959
4960/// Per-process per-mode decision counters (#1174 pm-v3.1 PR7).
4961///
4962/// Previously three sibling `static AtomicU64` items
4963/// (`DECISIONS_ENFORCE`/`_ADVISORY`/`_OFF`). Folding them into a
4964/// single struct keeps the in-memory layout identical (`#[repr(C)]`
4965/// is unnecessary — Rust's default field order is fine for the
4966/// atomic-counters-as-observability use case) while ensuring that
4967/// adding a fourth mode in the future requires a single grep-friendly
4968/// edit instead of N parallel static declarations.
4969///
4970/// `Relaxed` ordering is preserved everywhere the original three
4971/// statics used it: the counters are observability, not load-bearing
4972/// for correctness, and the inter-mode read consistency that an
4973/// `SeqCst` snapshot would buy is not exercised by any current caller
4974/// (`ai-memory doctor` + capabilities both render the snapshot as
4975/// three independent integers).
4976struct DecisionCounters {
4977    enforce: AtomicU64,
4978    advisory: AtomicU64,
4979    off: AtomicU64,
4980}
4981
4982impl DecisionCounters {
4983    const fn new() -> Self {
4984        Self {
4985            enforce: AtomicU64::new(0),
4986            advisory: AtomicU64::new(0),
4987            off: AtomicU64::new(0),
4988        }
4989    }
4990
4991    fn counter_for(&self, mode: PermissionsMode) -> &AtomicU64 {
4992        match mode {
4993            PermissionsMode::Enforce => &self.enforce,
4994            PermissionsMode::Advisory => &self.advisory,
4995            PermissionsMode::Off => &self.off,
4996        }
4997    }
4998}
4999
5000static DECISION_COUNTERS: DecisionCounters = DecisionCounters::new();
5001
5002/// Snapshot of decision counts per mode since process start. Surfaced
5003/// by `ai-memory doctor` and the capabilities `permissions` block so
5004/// operators can verify the gate is wired and observe drift between
5005/// "policies advertised" and "policies enforced".
5006#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
5007pub struct PermissionsDecisionCounts {
5008    pub enforce: u64,
5009    pub advisory: u64,
5010    pub off: u64,
5011}
5012
5013/// Increment the decision counter for `mode`. Called by the gate on
5014/// every consult. `Relaxed` is fine: the counters are observability,
5015/// not load-bearing for correctness.
5016pub fn record_permissions_decision(mode: PermissionsMode) {
5017    DECISION_COUNTERS
5018        .counter_for(mode)
5019        .fetch_add(1, Ordering::Relaxed);
5020}
5021
5022/// Snapshot the current per-mode decision counts.
5023#[must_use]
5024pub fn permissions_decision_counts() -> PermissionsDecisionCounts {
5025    PermissionsDecisionCounts {
5026        enforce: DECISION_COUNTERS.enforce.load(Ordering::Relaxed),
5027        advisory: DECISION_COUNTERS.advisory.load(Ordering::Relaxed),
5028        off: DECISION_COUNTERS.off.load(Ordering::Relaxed),
5029    }
5030}
5031
5032/// Test-only: zero the counters between scenarios so the K3 matrix
5033/// can assert exact deltas.
5034#[doc(hidden)]
5035pub fn reset_permissions_decision_counts_for_test() {
5036    DECISION_COUNTERS.enforce.store(0, Ordering::SeqCst);
5037    DECISION_COUNTERS.advisory.store(0, Ordering::SeqCst);
5038    DECISION_COUNTERS.off.store(0, Ordering::SeqCst);
5039}
5040
5041// ---------------------------------------------------------------------------
5042// Logging facility (PR-5)
5043// ---------------------------------------------------------------------------
5044
5045/// `[logging]` block in `config.toml`. Every field is `Option`; missing
5046/// fields fall back to the documented defaults.
5047#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5048pub struct LoggingConfig {
5049    /// Master toggle. Default `false`.
5050    pub enabled: Option<bool>,
5051    /// Directory for rotated logs. Default `~/.local/state/ai-memory/logs/`.
5052    pub path: Option<String>,
5053    /// Soft cap on a single rotated file (advisory — informs rotation
5054    /// configuration; the appender enforces this via the chosen
5055    /// `rotation` cadence). Default 100.
5056    pub max_size_mb: Option<u64>,
5057    /// Maximum number of rotated files retained on disk. Default 30.
5058    pub max_files: Option<usize>,
5059    /// Days of log history to keep before `ai-memory logs archive`
5060    /// would compress them. Default 90.
5061    pub retention_days: Option<u32>,
5062    /// Emit JSON lines instead of the human-readable fmt layer. Default `false`.
5063    pub structured: Option<bool>,
5064    /// Tracing level / `EnvFilter` directive. Default `"info"`.
5065    pub level: Option<String>,
5066    /// Rotation policy: `minutely | hourly | daily | never`. Default `"daily"`.
5067    pub rotation: Option<String>,
5068    /// Override the rotated-file prefix. Default `"ai-memory.log"`.
5069    pub filename_prefix: Option<String>,
5070}
5071
5072// ---------------------------------------------------------------------------
5073// Audit facility (PR-5)
5074// ---------------------------------------------------------------------------
5075
5076/// `[audit]` block in `config.toml`. Drives the hash-chained audit
5077/// trail emitted from every memory mutation call site.
5078#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5079pub struct AuditConfig {
5080    /// Master toggle. Default `false`.
5081    pub enabled: Option<bool>,
5082    /// Audit log path. Either a directory (in which case `audit.log`
5083    /// is appended) or an explicit file path. Default
5084    /// `~/.local/state/ai-memory/audit/`.
5085    pub path: Option<String>,
5086    /// Documented schema version on the wire. The binary always emits
5087    /// `audit::SCHEMA_VERSION`; this knob is reserved for forward
5088    /// compatibility and must equal the binary's emitted version
5089    /// today (validated at init).
5090    pub schema_version: Option<u32>,
5091    /// Whether to redact `memory.content` from emitted events. **The
5092    /// only supported value in v1 is `true`** — the audit schema does
5093    /// not expose a content field at all; this flag is reserved for a
5094    /// future per-namespace exception API.
5095    pub redact_content: Option<bool>,
5096    /// Whether to compute and verify the per-line hash chain. Default `true`.
5097    pub hash_chain: Option<bool>,
5098    /// Cadence in minutes for the periodic `CHECKPOINT.sig`
5099    /// attestation marker. The marker is a synthetic audit event that
5100    /// pins the chain head into the log so an attacker who truncates
5101    /// the file can't silently rewind history. Default 60. 0 disables.
5102    pub attestation_cadence_minutes: Option<u32>,
5103    /// Apply the platform-appropriate "append-only" file flag at
5104    /// startup. Best-effort defense in depth; the chain is the
5105    /// load-bearing tamper-evidence. Default `true`.
5106    pub append_only: Option<bool>,
5107    /// Retention horizon (days). `ai-memory logs purge` warns about
5108    /// deleting audit records younger than this, and `audit verify`
5109    /// surfaces gaps when retention is shorter than the chain extent.
5110    /// Default 90. Compliance presets override.
5111    pub retention_days: Option<u32>,
5112    /// Compliance presets — apply industry-standard retention /
5113    /// redaction policy on top of the base config. See
5114    /// `docs/security/audit-trail.md` §Compliance.
5115    pub compliance: Option<AuditComplianceConfig>,
5116}
5117
5118impl AuditConfig {
5119    /// Resolve the effective retention horizon after applying any
5120    /// active compliance preset. Presets win when `applied = true`;
5121    /// when multiple presets are applied the most-conservative
5122    /// (longest) retention wins so the binary never picks a value
5123    /// that violates any active policy.
5124    #[must_use]
5125    pub fn effective_retention_days(&self) -> u32 {
5126        let mut chosen = self.retention_days.unwrap_or(90);
5127        if let Some(comp) = &self.compliance {
5128            for preset in comp.applied_presets() {
5129                if let Some(d) = preset.retention_days
5130                    && d > chosen
5131                {
5132                    chosen = d;
5133                }
5134            }
5135        }
5136        chosen
5137    }
5138
5139    /// Resolve the effective attestation cadence — the most-frequent
5140    /// (smallest non-zero) cadence across the base config and applied
5141    /// presets so the strictest compliance rule wins.
5142    #[must_use]
5143    pub fn effective_attestation_cadence_minutes(&self) -> u32 {
5144        let base = self.attestation_cadence_minutes.unwrap_or(60);
5145        let mut chosen = base;
5146        if let Some(comp) = &self.compliance {
5147            for preset in comp.applied_presets() {
5148                if let Some(m) = preset.attestation_cadence_minutes
5149                    && m > 0
5150                    && (chosen == 0 || m < chosen)
5151                {
5152                    chosen = m;
5153                }
5154            }
5155        }
5156        chosen
5157    }
5158}
5159
5160// ---------------------------------------------------------------------------
5161// Boot privacy controls (PR-9h, v0.6.3.1, issue #487 PR #497 req #73)
5162// ---------------------------------------------------------------------------
5163
5164/// `[boot]` block in `config.toml`. Drives the privacy kill-switch +
5165/// title-redaction behaviour of `ai-memory boot`. Both fields default
5166/// to the historical (pre-v0.6.3.1) behaviour so existing users see no
5167/// change.
5168///
5169/// Precedence for `enabled`:
5170///   `AI_MEMORY_BOOT_ENABLED=0` env var (truthy "0/false/no/off") >
5171///   `[boot] enabled` config value > compiled default `true`.
5172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5173pub struct BootConfig {
5174    /// Master toggle. Default `true`. When set to `false`, `ai-memory
5175    /// boot` exits 0 with **empty stdout AND empty stderr** — the
5176    /// privacy-sensitive escape hatch for hosts where memory titles
5177    /// must never enter CI logs. The hook injects nothing.
5178    pub enabled: Option<bool>,
5179    /// When `true`, the manifest header still appears but every
5180    /// memory row's `title` field is replaced with `<redacted>` —
5181    /// useful for compliance contexts that need an audit trail of
5182    /// "boot ran with N memories" without exposing memory subjects.
5183    /// Default `false`.
5184    pub redact_titles: Option<bool>,
5185}
5186
5187impl BootConfig {
5188    /// Resolve the effective `enabled` value with env-var precedence.
5189    /// `AI_MEMORY_BOOT_ENABLED=0/false/no/off` forces disabled;
5190    /// `=1/true/yes/on` forces enabled. Anything else falls through to
5191    /// the config file value (or the compiled default `true`).
5192    #[must_use]
5193    pub fn effective_enabled(&self) -> bool {
5194        if let Ok(v) = std::env::var("AI_MEMORY_BOOT_ENABLED") {
5195            let v = v.trim().to_ascii_lowercase();
5196            if matches!(v.as_str(), "0" | "false" | "no" | "off") {
5197                return false;
5198            }
5199            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
5200                return true;
5201            }
5202        }
5203        self.enabled.unwrap_or(true)
5204    }
5205
5206    /// Resolve the effective `redact_titles` value. Default `false`.
5207    #[must_use]
5208    pub fn effective_redact_titles(&self) -> bool {
5209        self.redact_titles.unwrap_or(false)
5210    }
5211}
5212
5213// ---------------------------------------------------------------------------
5214// MCP server tunables (v0.6.4)
5215// ---------------------------------------------------------------------------
5216
5217/// `[mcp]` block in `config.toml` — v0.6.4 addition. Today this only
5218/// carries the named tool `profile`. v0.6.4 Track D will extend with
5219/// `[mcp.allowlist]` for per-agent capability gating.
5220///
5221/// Resolution for `profile`: CLI flag > `AI_MEMORY_PROFILE` env (both
5222/// merged by clap) > this config field > compiled default `"core"`.
5223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5224pub struct McpConfig {
5225    /// Named tool profile. One of `core`, `graph`, `admin`, `power`,
5226    /// `full`, or a comma-separated custom list (e.g.,
5227    /// `core,graph,archive`). Default `core` (v0.6.4 default flip).
5228    pub profile: Option<String>,
5229
5230    /// v0.6.4-008 — per-agent capability allowlist. Maps an agent_id
5231    /// pattern to the families that agent may request via
5232    /// `memory_capabilities --include-schema family=<f>`. Patterns
5233    /// resolve to a Vec<String> (the family names). The wildcard
5234    /// pattern `"*"` is the default for agents not otherwise listed.
5235    /// When the entire allowlist is absent (`mcp.allowlist = None`),
5236    /// the gate is disabled — every caller may expand any family
5237    /// (Tier-1 single-process semantics, profile flag rules).
5238    ///
5239    /// Example config.toml:
5240    /// ```toml
5241    /// [mcp.allowlist]
5242    /// "alice" = ["core", "graph"]
5243    /// "bob"   = ["full"]
5244    /// "*"     = ["core"]
5245    /// ```
5246    pub allowlist: Option<std::collections::HashMap<String, Vec<String>>>,
5247
5248    /// #1254 (MED, 2026-05-25) — error-oracle posture for
5249    /// `tools/call` against a tool that exists but is not loaded
5250    /// under the active profile.
5251    ///
5252    /// Default `false` (production-secure): the daemon returns a
5253    /// minimal `"unknown tool: <name>"` regardless of whether the
5254    /// tool exists in another family. This prevents a lower-profile
5255    /// client from probing the surface of a higher-profile tool set
5256    /// (e.g. `admin` or `power` family names) by walking error
5257    /// messages.
5258    ///
5259    /// Set to `true` to restore the v0.6.4-002 helpful hint
5260    /// ("tool 'X' is in family 'Y' which is not loaded under the
5261    /// active profile. Restart with `--profile <name>` ..."). The
5262    /// hint is convenient for single-tenant dev environments where
5263    /// every operator sees the full surface anyway, but leaks
5264    /// family membership in any multi-tenant deployment.
5265    #[serde(default)]
5266    pub profile_hint_in_errors: bool,
5267}
5268
5269impl McpConfig {
5270    /// v0.6.4-008 — resolve the allowlist decision for an agent
5271    /// requesting a family.
5272    ///
5273    /// Returns:
5274    /// - `AllowlistDecision::Disabled` if the entire allowlist is
5275    ///   absent (Tier-1 default — gate is off).
5276    /// - `AllowlistDecision::Allow` if a matching pattern includes
5277    ///   the requested family (or `"full"`).
5278    /// - `AllowlistDecision::Deny` if a pattern matches but does
5279    ///   not list the family.
5280    /// - `AllowlistDecision::Deny` if no pattern matches and there
5281    ///   is no `"*"` wildcard.
5282    ///
5283    /// Pattern matching: exact match wins; otherwise the wildcard
5284    /// `"*"` is consulted. Multiple-pattern precedence follows
5285    /// longest-prefix order with stable tie-break by config order
5286    /// (since `HashMap` is unordered, we sort by key length
5287    /// descending for the comparison).
5288    #[must_use]
5289    pub fn allowlist_decision(&self, agent_id: Option<&str>, family: &str) -> AllowlistDecision {
5290        let table = match self.allowlist.as_ref() {
5291            Some(t) if !t.is_empty() => t,
5292            _ => return AllowlistDecision::Disabled,
5293        };
5294        // Tier-1: no agent_id → only the wildcard rule applies. Same
5295        // restrictive default as for an unknown agent.
5296        let aid = agent_id.unwrap_or("");
5297        // Exact match first.
5298        if let Some(families) = table.get(aid) {
5299            return decide(families, family);
5300        }
5301        // Longest-prefix match next (excluding `"*"`).
5302        let mut keys: Vec<&String> = table
5303            .keys()
5304            .filter(|k| k.as_str() != "*" && aid.starts_with(k.as_str()))
5305            .collect();
5306        keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
5307        if let Some(k) = keys.first() {
5308            if let Some(families) = table.get(*k) {
5309                return decide(families, family);
5310            }
5311        }
5312        // Wildcard fallback.
5313        if let Some(families) = table.get("*") {
5314            return decide(families, family);
5315        }
5316        AllowlistDecision::Deny
5317    }
5318}
5319
5320fn decide(families: &[String], requested: &str) -> AllowlistDecision {
5321    if families.iter().any(|f| f == "full" || f == requested) {
5322        AllowlistDecision::Allow
5323    } else {
5324        AllowlistDecision::Deny
5325    }
5326}
5327
5328/// v0.6.4-008 — outcome of an allowlist check.
5329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5330pub enum AllowlistDecision {
5331    /// Allowlist is not configured; no gate.
5332    Disabled,
5333    /// Pattern match grants access to the requested family.
5334    Allow,
5335    /// Pattern match denies (or no pattern matched).
5336    Deny,
5337}
5338
5339#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5340pub struct AuditComplianceConfig {
5341    pub soc2: Option<CompliancePreset>,
5342    pub hipaa: Option<CompliancePreset>,
5343    pub gdpr: Option<CompliancePreset>,
5344    pub fedramp: Option<CompliancePreset>,
5345}
5346
5347impl AuditComplianceConfig {
5348    /// Iterate over every preset whose `applied = true`.
5349    pub fn applied_presets(&self) -> impl Iterator<Item = &CompliancePreset> {
5350        [
5351            self.soc2.as_ref(),
5352            self.hipaa.as_ref(),
5353            self.gdpr.as_ref(),
5354            self.fedramp.as_ref(),
5355        ]
5356        .into_iter()
5357        .flatten()
5358        .filter(|p| p.applied.unwrap_or(false))
5359    }
5360}
5361
5362#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5363pub struct CompliancePreset {
5364    pub applied: Option<bool>,
5365    pub retention_days: Option<u32>,
5366    pub redact_content: Option<bool>,
5367    pub attestation_cadence_minutes: Option<u32>,
5368    /// Reserved for compliance contexts that mandate at-rest crypto.
5369    /// HIPAA preset surfaces this so operators can pair audit with
5370    /// `--features sqlcipher` for end-to-end at-rest encryption.
5371    pub encrypt_at_rest: Option<bool>,
5372    /// GDPR-style actor pseudonymization toggle. Reserved for v0.7+.
5373    pub pseudonymize_actors: Option<bool>,
5374}
5375
5376/// Identity-resolution configuration (Task 1.2 follow-up #198).
5377///
5378/// Lets operators opt out of the default `host:<hostname>:pid-<pid>-<uuid8>`
5379/// fallback when no explicit `agent_id` is supplied. `anonymize_default = true`
5380/// swaps the hostname-revealing default for `anonymous:pid-<pid>-<uuid8>`,
5381/// matching what the `AI_MEMORY_ANONYMIZE=1` env var does ephemerally.
5382#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5383pub struct IdentityConfig {
5384    /// When true, the "no flag, no env, no MCP clientInfo" fallback uses
5385    /// `anonymous:pid-<pid>-<uuid8>` instead of the hostname-revealing
5386    /// `host:<hostname>:pid-<pid>-<uuid8>`. Default false.
5387    #[serde(default)]
5388    pub anonymize_default: bool,
5389}
5390
5391/// v0.7.0 (issue #518) — parse a duration string of the form
5392/// `"<integer><unit>"` into a `chrono::Duration`. Supported units:
5393/// `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks).
5394/// Whitespace and case are tolerated. Returns `None` on malformed
5395/// input — the caller falls through to "no since filter applied".
5396///
5397/// Intentionally a small bespoke parser rather than a `humantime`
5398/// dependency: the surface we need is tiny (4-5 units) and operators
5399/// expect the same shape they already type into `--since` flags.
5400#[must_use]
5401pub fn parse_duration_string(s: &str) -> Option<chrono::Duration> {
5402    let trimmed = s.trim().to_ascii_lowercase();
5403    if trimmed.is_empty() {
5404        return None;
5405    }
5406    let (num_part, unit_part) = trimmed.split_at(
5407        trimmed
5408            .find(|c: char| !c.is_ascii_digit())
5409            .unwrap_or(trimmed.len()),
5410    );
5411    let n: i64 = num_part.parse().ok()?;
5412    if n < 0 {
5413        return None;
5414    }
5415    match unit_part.trim() {
5416        "s" | "sec" | "secs" | "second" | "seconds" => Some(chrono::Duration::seconds(n)),
5417        "m" | "min" | "mins" | "minute" | "minutes" => Some(chrono::Duration::minutes(n)),
5418        "h" | "hr" | "hrs" | "hour" | "hours" => Some(chrono::Duration::hours(n)),
5419        "d" | "day" | "days" => Some(chrono::Duration::days(n)),
5420        "w" | "wk" | "wks" | "week" | "weeks" => Some(chrono::Duration::weeks(n)),
5421        _ => None,
5422    }
5423}
5424
5425/// Expand a leading `~` or `~/` in a path string to `$HOME`. POSIX-style.
5426/// `~user/...` is not supported (rare in our deployment surface, and supporting
5427/// it requires `getpwnam` — out of scope for the #507 fix). When `$HOME` is
5428/// unset (no-home environments like some CI containers), the tilde is left
5429/// untouched so the existing failure mode (path not found) is preserved
5430/// rather than silently rewriting to an empty prefix.
5431// ---------------------------------------------------------------------------
5432// Resolver helpers (#1146)
5433// ---------------------------------------------------------------------------
5434
5435/// Backend-specific default model identifier. Used by
5436/// [`AppConfig::resolve_llm`] when no model is configured at any
5437/// precedence layer.
5438fn backend_default_model(backend: &str) -> &'static str {
5439    match backend {
5440        "xai" => "grok-4.3",
5441        "openai" => "gpt-5",
5442        "anthropic" => "claude-opus-4.7",
5443        "gemini" => "gemini-2.0-flash",
5444        "deepseek" => "deepseek-chat",
5445        "kimi" | "moonshot" => "moonshot-v1-8k",
5446        "qwen" | "dashscope" => "qwen-max",
5447        "mistral" => "mistral-large-latest",
5448        "groq" => "llama-3.3-70b-versatile",
5449        "together" => "meta-llama/Llama-3.3-70B-Instruct-Turbo",
5450        "cerebras" => "llama-3.3-70b",
5451        "openrouter" => "openai/gpt-5",
5452        "fireworks" => "accounts/fireworks/models/llama-v3p3-70b-instruct",
5453        "lmstudio" => "local-model",
5454        // ollama / openai-compatible / any unknown alias → legacy default.
5455        _ => "gemma3:4b",
5456    }
5457}
5458
5459/// Backend-specific default base URL. Used by
5460/// [`AppConfig::resolve_llm`] when no base_url is configured at any
5461/// precedence layer. `openai-compatible` returns the empty string (the
5462/// resolver does not validate this — surface plumbing surfaces the
5463/// misconfiguration via the reachability probe in `ai-memory doctor`).
5464fn backend_default_base_url(backend: &str) -> &'static str {
5465    match backend {
5466        "openai" => "https://api.openai.com/v1",
5467        "xai" => "https://api.x.ai/v1",
5468        "anthropic" => "https://api.anthropic.com/v1",
5469        "gemini" => "https://generativelanguage.googleapis.com/v1beta/openai",
5470        "deepseek" => "https://api.deepseek.com/v1",
5471        "kimi" | "moonshot" => "https://api.moonshot.cn/v1",
5472        "qwen" | "dashscope" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
5473        "mistral" => "https://api.mistral.ai/v1",
5474        "groq" => "https://api.groq.com/openai/v1",
5475        "together" => "https://api.together.xyz/v1",
5476        "cerebras" => "https://api.cerebras.ai/v1",
5477        "openrouter" => "https://openrouter.ai/api/v1",
5478        "fireworks" => "https://api.fireworks.ai/inference/v1",
5479        "lmstudio" => "http://localhost:1234/v1",
5480        // ollama / openai-compatible / unknown → localhost ollama.
5481        _ => "http://localhost:11434",
5482    }
5483}
5484
5485/// Per-alias environment variable fallback chain for the API key.
5486/// Mirrors `crate::llm::alias_api_key_env_vars` (kept duplicated to
5487/// avoid a circular dependency between the resolver and the LLM
5488/// client; both lists must stay in sync — pinned by a test in
5489/// commit 12/13).
5490fn alias_api_key_env_vars_for_resolver(alias: &str) -> &'static [&'static str] {
5491    match alias {
5492        "openai" => &["OPENAI_API_KEY"],
5493        "xai" => &["XAI_API_KEY"],
5494        "anthropic" => &["ANTHROPIC_API_KEY"],
5495        "gemini" => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
5496        "deepseek" => &["DEEPSEEK_API_KEY"],
5497        "kimi" | "moonshot" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
5498        "qwen" | "dashscope" => &["DASHSCOPE_API_KEY", "QWEN_API_KEY"],
5499        "mistral" => &["MISTRAL_API_KEY"],
5500        "groq" => &["GROQ_API_KEY"],
5501        "together" => &["TOGETHER_API_KEY"],
5502        "cerebras" => &["CEREBRAS_API_KEY"],
5503        "openrouter" => &["OPENROUTER_API_KEY"],
5504        "fireworks" => &["FIREWORKS_API_KEY"],
5505        _ => &[],
5506    }
5507}
5508
5509/// Canonicalise legacy embedding-model aliases to the HF-id form. Lets
5510/// existing config.toml files with `embedding_model = "nomic_embed_v15"`
5511/// continue to work while the resolver returns the canonical id used
5512/// throughout the substrate.
5513fn canonicalise_embedding_model(raw: String) -> String {
5514    match raw.trim() {
5515        "nomic_embed_v15" => "nomic-embed-text-v1.5".to_string(),
5516        "mini_lm_l6_v2" => "sentence-transformers/all-MiniLM-L6-v2".to_string(),
5517        _ => raw,
5518    }
5519}
5520
5521/// v0.7.x (issue #1169) — known canonical embedding-model id → vector
5522/// dim mappings.
5523///
5524/// Used by [`canonical_embedding_dim`] (resolver-side) and
5525/// [`build_capability_models`] (capabilities-surface side) so the
5526/// reported `embedding_dim` reflects the live model the embedder
5527/// produces vectors of, NOT the compiled tier preset's hardcoded dim.
5528/// Pre-#1169 the dim was sourced only from the 2-family
5529/// [`EmbeddingModel`] enum — picking any other model id (e.g. Ollama
5530/// `bge-large-en`) silently fell back to the tier preset's wrong dim.
5531///
5532/// New entries land here when an operator adopts a model not yet
5533/// covered. Unknown models resolve to `None`
5534/// ([`canonical_embedding_dim`] return), which causes
5535/// [`build_capability_models`] to fall back to the tier preset's dim
5536/// — preserving the pre-#1169 behaviour for unrecognised ids and
5537/// avoiding the silent-wrong-dim trap for recognised ones.
5538///
5539/// Match keys are case-insensitive (lookup uses
5540/// `eq_ignore_ascii_case`) and span the canonical HF id, the
5541/// unprefixed shortname, and the common Ollama tag where they
5542/// diverge. Matches whatever the operator actually wrote in
5543/// `[embeddings].model` post-`canonicalise_embedding_model`.
5544pub const KNOWN_EMBEDDING_DIMS: &[(&str, u32)] = &[
5545    // nomic-ai (default for the v0.7.0 autonomous tier)
5546    ("nomic-embed-text-v1.5", 768),
5547    ("nomic-embed-text", 768),
5548    ("nomic-ai/nomic-embed-text-v1.5", 768),
5549    // sentence-transformers / MiniLM family
5550    ("sentence-transformers/all-MiniLM-L6-v2", 384),
5551    ("all-MiniLM-L6-v2", 384),
5552    ("all-minilm", 384),
5553    ("all-minilm:l6-v2", 384),
5554    // BAAI BGE family (common Ollama-side operator picks — the #1169
5555    // repro example was bge-large-en)
5556    ("bge-large-en", 1024),
5557    ("bge-large-en-v1.5", 1024),
5558    ("baai/bge-large-en-v1.5", 1024),
5559    ("bge-base-en", 768),
5560    ("bge-base-en-v1.5", 768),
5561    ("baai/bge-base-en-v1.5", 768),
5562    ("bge-small-en", 384),
5563    ("bge-small-en-v1.5", 384),
5564    ("baai/bge-small-en-v1.5", 384),
5565    ("bge-m3", 1024),
5566    ("baai/bge-m3", 1024),
5567    // Mixed Bread AI
5568    ("mxbai-embed-large", 1024),
5569    ("mxbai-embed-large-v1", 1024),
5570    ("mixedbread-ai/mxbai-embed-large-v1", 1024),
5571    // OpenAI text-embedding family
5572    ("text-embedding-3-small", 1536),
5573    ("text-embedding-3-large", 3072),
5574    ("text-embedding-ada-002", 1536),
5575    // Google embedding
5576    ("embedding-001", 768),
5577    ("text-embedding-004", 768),
5578    ("google/gemini-embedding-2", 3072),
5579    ("gemini-embedding-2", 3072),
5580    // IBM Granite (#1598 — common self-hosted TEI/vLLM pick)
5581    ("ibm-granite/granite-embedding-125m-english", 768),
5582    ("granite-embedding", 768),
5583    // Snowflake Arctic
5584    ("snowflake-arctic-embed", 1024),
5585    ("snowflake-arctic-embed:l", 1024),
5586    ("snowflake-arctic-embed-l", 1024),
5587    ("snowflake-arctic-embed:m", 768),
5588    ("snowflake-arctic-embed:s", 384),
5589];
5590
5591/// v0.7.x (issue #1169) — look up the vector dim for a canonical
5592/// embedding model id. Returns `None` when the model is not in the
5593/// [`KNOWN_EMBEDDING_DIMS`] table; callers fall back to the tier
5594/// preset (preserving pre-#1169 behaviour for unrecognised ids).
5595///
5596/// The lookup is case-insensitive and ignores leading/trailing
5597/// whitespace. Matches the canonicalised form
5598/// ([`canonicalise_embedding_model`] runs first), so the table
5599/// keys are the HF-id / Ollama tag forms operators actually set in
5600/// `[embeddings].model` after legacy-alias canonicalisation.
5601#[must_use]
5602pub fn canonical_embedding_dim(model: &str) -> Option<u32> {
5603    let needle = model.trim();
5604    if needle.is_empty() {
5605        return None;
5606    }
5607    KNOWN_EMBEDDING_DIMS
5608        .iter()
5609        .find(|(id, _)| id.eq_ignore_ascii_case(needle))
5610        .map(|(_, dim)| *dim)
5611}
5612
5613/// Resolve the API key + provenance tag for the configured backend.
5614///
5615/// Precedence:
5616///   1. `AI_MEMORY_LLM_API_KEY` process env → `KeySource::ProcessEnv`
5617///   2. Per-vendor process env-var fallback (e.g. `XAI_API_KEY`)
5618///      → `KeySource::AliasFallback(name)`
5619///   3. `[llm].api_key_env` → `KeySource::ConfigEnvVar(name)`
5620///   4. `[llm].api_key_file` → `KeySource::ConfigFile(path)`
5621///   5. None resolved → `KeySource::None` (correct for `backend =
5622///      "ollama"`; a misconfiguration for OpenAI-compatible vendors —
5623///      surfaced by the reachability probe).
5624///
5625/// #1598 — thin delegate over [`resolve_api_key_ladder`] (the same
5626/// ladder serves the `[embeddings]` section via
5627/// [`resolve_embed_api_key`]).
5628fn resolve_api_key(backend: &str, llm: Option<&LlmSection>) -> (Option<String>, KeySource) {
5629    resolve_api_key_ladder(
5630        ENV_LLM_API_KEY,
5631        backend,
5632        llm.and_then(|l| l.api_key_env.as_deref()),
5633        llm.and_then(|l| l.api_key_file.as_deref()),
5634        "llm",
5635    )
5636}
5637
5638/// #1598 — resolve the EMBEDDING API key + provenance tag for the
5639/// configured embedding backend. Mirrors [`resolve_api_key`] with the
5640/// `[embeddings]`-section sources:
5641///
5642///   1. `AI_MEMORY_EMBED_API_KEY` process env → `KeySource::ProcessEnv`
5643///   2. Per-vendor process env-var fallback (e.g. `OPENROUTER_API_KEY`)
5644///      → `KeySource::AliasFallback(name)`
5645///   3. `[embeddings].api_key_env` → `KeySource::ConfigEnvVar(name)`
5646///   4. `[embeddings].api_key_file` (0400 enforced)
5647///      → `KeySource::ConfigFile(path)`
5648///   5. None resolved → `KeySource::None` (correct for `backend =
5649///      "ollama"` and for keyless self-hosted OpenAI-compatible
5650///      endpoints such as HF TEI / vLLM).
5651fn resolve_embed_api_key(
5652    backend: &str,
5653    embeddings: Option<&EmbeddingsSection>,
5654) -> (Option<String>, KeySource) {
5655    resolve_api_key_ladder(
5656        ENV_EMBED_API_KEY,
5657        backend,
5658        embeddings.and_then(|e| e.api_key_env.as_deref()),
5659        embeddings.and_then(|e| e.api_key_file.as_deref()),
5660        "embeddings",
5661    )
5662}
5663
5664/// #1598 — true when the embedding backend speaks an API wire shape
5665/// (OpenAI-compatible `/embeddings` + Bearer auth) rather than the
5666/// local Ollama-native `/api/embed` shape. `"ollama"` is the ONLY
5667/// non-API backend; every #1067 alias and the generic
5668/// `openai-compatible` escape hatch classify as API backends. Sits
5669/// next to [`alias_api_key_env_vars_for_resolver`] /
5670/// [`backend_default_base_url`] — the alias machinery it complements.
5671#[must_use]
5672pub fn is_api_embed_backend(backend: &str) -> bool {
5673    !backend
5674        .trim()
5675        .eq_ignore_ascii_case(crate::llm::BACKEND_OLLAMA)
5676}
5677
5678/// Shared API-key resolution ladder for the `[llm]` and `[embeddings]`
5679/// sections (#1146 / #1598). `primary_env` is the section's dedicated
5680/// `AI_MEMORY_*_API_KEY` env var; `section` is the bare section name
5681/// (`"llm"` / `"embeddings"`) used in provenance / error strings.
5682///
5683/// File reads enforce mode 0400 (via [`enforce_api_key_file_perms`])
5684/// and surface failures as `KeySource::Error(reason)` so the daemon
5685/// can boot and report the problem through `ai-memory doctor` rather
5686/// than failing at config load.
5687fn resolve_api_key_ladder(
5688    primary_env: &str,
5689    backend: &str,
5690    api_key_env: Option<&str>,
5691    api_key_file: Option<&str>,
5692    section: &str,
5693) -> (Option<String>, KeySource) {
5694    // 1. Process env (highest).
5695    if let Some(k) = std::env::var(primary_env)
5696        .ok()
5697        .filter(|s| !s.trim().is_empty())
5698    {
5699        return (Some(k), KeySource::ProcessEnv);
5700    }
5701
5702    // 2. Per-vendor alias fallback.
5703    for name in alias_api_key_env_vars_for_resolver(backend) {
5704        if let Some(k) = std::env::var(name).ok().filter(|s| !s.trim().is_empty()) {
5705            return (Some(k), KeySource::AliasFallback((*name).to_string()));
5706        }
5707    }
5708
5709    // 3. config-pointed env var.
5710    if let Some(name) = api_key_env.filter(|s| !s.trim().is_empty()) {
5711        return match std::env::var(name) {
5712            Ok(v) if !v.trim().is_empty() => (Some(v), KeySource::ConfigEnvVar(name.to_string())),
5713            Ok(_) => (
5714                None,
5715                KeySource::Error(format!(
5716                    "[{section}].api_key_env = {name:?} resolves to an empty env var"
5717                )),
5718            ),
5719            Err(_) => (
5720                None,
5721                KeySource::Error(format!(
5722                    "[{section}].api_key_env = {name:?} is not set in the process env"
5723                )),
5724            ),
5725        };
5726    }
5727
5728    // 4. config-pointed file.
5729    if let Some(raw_path) = api_key_file.filter(|s| !s.trim().is_empty()) {
5730        let field = format!("[{section}].api_key_file");
5731        let path = expand_tilde(raw_path);
5732        let path_display = path.display().to_string();
5733
5734        // Mode 0400 enforcement (#1055-style escape hatch).
5735        if let Err(reason) = enforce_api_key_file_perms(&path, &field) {
5736            return (None, KeySource::Error(reason));
5737        }
5738
5739        return match std::fs::read_to_string(&path) {
5740            Ok(contents) => {
5741                let key = contents.lines().next().unwrap_or("").trim().to_string();
5742                if key.is_empty() {
5743                    (
5744                        None,
5745                        KeySource::Error(format!("{field} = {path_display:?} is empty")),
5746                    )
5747                } else {
5748                    (Some(key), KeySource::ConfigFile(path_display))
5749                }
5750            }
5751            Err(e) => (
5752                None,
5753                KeySource::Error(format!("{field} = {path_display:?} could not be read: {e}")),
5754            ),
5755        };
5756    }
5757
5758    (None, KeySource::None)
5759}
5760
5761/// v0.7.x (#1146) — enforce mode 0400 (or stricter) on the file
5762/// referenced by `[llm].api_key_file` / `[embeddings].api_key_file`
5763/// (#1598; `field` names the rejecting config field in error text).
5764/// The check mirrors the existing `AI_MEMORY_DB_PASSPHRASE_FILE`
5765/// enforcement (issue #1055): any bits set in `mode & 0o077` (group /
5766/// world readable / executable) cause the daemon to refuse the file,
5767/// unless the operator opts out via
5768/// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1`.
5769///
5770/// On non-Unix platforms (the `staticlib` mobile target, future
5771/// Windows builds) the check is a no-op — the perm bits are not
5772/// expressible on those platforms.
5773fn enforce_api_key_file_perms(path: &Path, field: &str) -> Result<(), String> {
5774    #[cfg(unix)]
5775    {
5776        use std::os::unix::fs::PermissionsExt;
5777        let metadata = std::fs::metadata(path).map_err(|e| {
5778            format!(
5779                "{field} = {:?} could not be stat'd for perms check: {e}",
5780                path.display(),
5781            )
5782        })?;
5783        let mode = metadata.permissions().mode();
5784        if mode & 0o077 != 0 {
5785            // Allow lax perms only when the operator explicitly opts in
5786            // (mirroring #1055 for AI_MEMORY_DB_PASSPHRASE_FILE).
5787            let opt_in = std::env::var("AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS")
5788                .ok()
5789                .is_some_and(|s| {
5790                    let t = s.trim().to_ascii_lowercase();
5791                    matches!(t.as_str(), "1" | "true" | "yes" | "on")
5792                });
5793            if !opt_in {
5794                return Err(format!(
5795                    "{field} = {:?} has lax permissions \
5796                     (mode = {:o}; expected 0400 or stricter). Run \
5797                     `chmod 0400 {}` to fix, or set \
5798                     `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1` to \
5799                     bypass (NOT recommended for production).",
5800                    path.display(),
5801                    mode & 0o777,
5802                    path.display()
5803                ));
5804            }
5805            tracing::warn!(
5806                "{field} = {:?} has lax permissions (mode = {:o}); \
5807                 accepted because AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1",
5808                path.display(),
5809                mode & 0o777
5810            );
5811        }
5812    }
5813    #[cfg(not(unix))]
5814    {
5815        // Permission bits do not apply on non-Unix platforms.
5816        let _ = (path, field);
5817    }
5818    Ok(())
5819}
5820
5821fn expand_tilde(s: &str) -> PathBuf {
5822    if s == "~" {
5823        return std::env::var("HOME").map_or_else(|_| PathBuf::from(s), PathBuf::from);
5824    }
5825    if let Some(rest) = s.strip_prefix("~/") {
5826        return std::env::var("HOME")
5827            .map_or_else(|_| PathBuf::from(s), |h| PathBuf::from(h).join(rest));
5828    }
5829    PathBuf::from(s)
5830}
5831
5832impl AppConfig {
5833    /// Returns the config file path: `~/.config/ai-memory/config.toml`
5834    pub fn config_path() -> Option<PathBuf> {
5835        let home = std::env::var("HOME").ok()?;
5836        Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
5837    }
5838
5839    /// Load config from disk. Returns `AppConfig::default()` if file is missing.
5840    /// Set `AI_MEMORY_NO_CONFIG=1` to skip config loading (used by integration tests).
5841    pub fn load() -> Self {
5842        if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
5843            return Self::default();
5844        }
5845        let Some(path) = Self::config_path() else {
5846            return Self::default();
5847        };
5848        Self::load_from(&path)
5849    }
5850
5851    /// Load config from a specific path.
5852    pub fn load_from(path: &Path) -> Self {
5853        match std::fs::read_to_string(path) {
5854            Ok(contents) => {
5855                // L1 fix (v0.7.0): warn on unknown top-level keys.
5856                // `serde(deny_unknown_fields)` would be a breaking change for
5857                // operators carrying forward-compat config snippets, so we
5858                // instead parse the document twice: once as a generic
5859                // `toml::Value` to enumerate every top-level key, and once
5860                // into `AppConfig` as before. Any top-level key that is not
5861                // part of the expected `AppConfig` field set is reported via
5862                // `tracing::warn!` and otherwise silently ignored — load
5863                // continues to succeed so a typo or stale Plan C section
5864                // (`[memory]`, `[autonomous]`, `[governance]`, `[federation]`)
5865                // can no longer silently neutralise an operator's intent.
5866                Self::warn_unknown_top_level_keys(path, &contents);
5867                match toml::from_str::<Self>(&contents) {
5868                    Ok(cfg) => match cfg.validate_secret_handling() {
5869                        Ok(()) => {
5870                            eprintln!("ai-memory: loaded config from {}", path.display());
5871                            cfg.warn_legacy_schema_drift(path);
5872                            cfg
5873                        }
5874                        Err(reason) => {
5875                            eprintln!(
5876                                "ai-memory: config rejected ({}): {}\n\
5877                                 ai-memory: falling back to default config — \
5878                                 fix the issue and restart. \
5879                                 See https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
5880                                path.display(),
5881                                reason
5882                            );
5883                            Self::default()
5884                        }
5885                    },
5886                    Err(e) => {
5887                        eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
5888                        Self::default()
5889                    }
5890                }
5891            }
5892            Err(_) => Self::default(),
5893        }
5894    }
5895
5896    /// v0.7.x (#1146) — emit a one-shot deprecation WARN to stderr
5897    /// when the loaded config carries legacy v1 flat fields that have
5898    /// been superseded by the sectioned v2 schema.
5899    ///
5900    /// Two posture WARNs:
5901    ///
5902    /// - **Legacy-only** (no `schema_version` OR `schema_version = 1`,
5903    ///   AND any of `llm_model`, `ollama_url`, `embed_url`,
5904    ///   `embedding_model`, `cross_encoder`, `default_namespace`,
5905    ///   `archive_on_gc`, `archive_max_days`, `max_memory_mb`,
5906    ///   `auto_tag_model` set): operator running pre-#1146 config
5907    ///   shape — point them at `ai-memory config migrate`.
5908    ///
5909    /// - **Drift** (`schema_version >= 2` AND any legacy field set):
5910    ///   operator has migrated but left legacy fields in place —
5911    ///   legacy fields are ignored under v2, point them at
5912    ///   `ai-memory config migrate` to clean up the dead weight.
5913    ///
5914    /// The WARN is gated by [`std::sync::Once`] so re-loading the
5915    /// config in the same process (e.g. tests that call
5916    /// [`AppConfig::load_from`] in a loop) does not spam stderr.
5917    ///
5918    /// DOC-6 (FX-C4-batch2, 2026-05-26): the v2 sectioned schema
5919    /// resolution path intentionally reads the legacy fields to
5920    /// emit the warn — the `#[allow(deprecated)]` is scoped here
5921    /// so the WARN site (the only thing that legitimately TOUCHES
5922    /// the legacy fields post-#1146) doesn't cascade pedantic
5923    /// errors. External consumers writing `let cfg: AppConfig =
5924    /// ...; cfg.llm_model` still get the compile-time deprecation
5925    /// warning.
5926    #[allow(deprecated)]
5927    fn warn_legacy_schema_drift(&self, path: &Path) {
5928        use std::sync::Once;
5929        static WARN_ONCE: Once = Once::new();
5930
5931        let has_legacy = self.llm_model.is_some()
5932            || self.ollama_url.is_some()
5933            || self.embed_url.is_some()
5934            || self.embedding_model.is_some()
5935            || self.cross_encoder.is_some()
5936            || self.default_namespace.is_some()
5937            || self.archive_on_gc.is_some()
5938            || self.archive_max_days.is_some()
5939            || self.max_memory_mb.is_some()
5940            || self.auto_tag_model.is_some();
5941
5942        if !has_legacy {
5943            return;
5944        }
5945
5946        let v2 = matches!(self.schema_version, Some(v) if v >= 2);
5947
5948        WARN_ONCE.call_once(|| {
5949            if v2 {
5950                eprintln!(
5951                    "ai-memory: WARN — schema_version = {:?} but legacy v1 fields \
5952                     are still present in {} (llm_model / ollama_url / embed_url / \
5953                     embedding_model / cross_encoder / default_namespace / \
5954                     archive_on_gc / archive_max_days / max_memory_mb / \
5955                     auto_tag_model). Under v2 the legacy fields are IGNORED in \
5956                     favor of [llm] / [embeddings] / [reranker] / [storage] \
5957                     sections. Run `ai-memory config migrate` to remove them.",
5958                    self.schema_version,
5959                    path.display(),
5960                );
5961            } else {
5962                eprintln!(
5963                    "ai-memory: WARN — legacy v1 flat-field configuration shape \
5964                     detected in {}. The [llm] / [embeddings] / [reranker] / \
5965                     [storage] sectioned schema (v2) is the canonical shape; \
5966                     legacy fields continue to work in v0.7.x but will be \
5967                     removed in v0.8.0. Run `ai-memory config migrate` to \
5968                     upgrade in place (a timestamped .bak is written). See \
5969                     https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
5970                    path.display(),
5971                );
5972            }
5973        });
5974    }
5975
5976    /// v0.7.x (#1146) — validate secret-handling discipline in the
5977    /// `[llm]` (and `[llm.auto_tag]`) sections after parse. Three
5978    /// rejections fire at load time so misconfigurations are loud
5979    /// rather than silent:
5980    ///
5981    /// 1. Inline `api_key = "<literal>"` in `[llm]`. Operators MUST
5982    ///    use `api_key_env = "<ENV_VAR_NAME>"` or `api_key_file =
5983    ///    "/path/to/key"` instead. Closes the v0.6.x posture where
5984    ///    inline secrets in `~/.config/ai-memory/config.toml` were
5985    ///    silently accepted even though the file is typically
5986    ///    world-readable.
5987    ///
5988    /// 2. Both `api_key_env` and `api_key_file` set on `[llm]`.
5989    ///    Mutually exclusive — operator must pick one.
5990    ///
5991    /// 3. Both `api_key_env` and `api_key_file` set on
5992    ///    `[llm.auto_tag]`. Same mutex.
5993    ///
5994    /// 4. (#1598) Inline `api_key = "<literal>"` in `[embeddings]` —
5995    ///    same posture as rejection 1.
5996    ///
5997    /// 5. (#1598) Both `api_key_env` and `api_key_file` set on
5998    ///    `[embeddings]`. Same mutex as rejection 2.
5999    ///
6000    /// On any rejection, [`Self::load_from`] surfaces the message to
6001    /// stderr and falls back to [`Self::default`] so the daemon boots
6002    /// without the misconfigured secret rather than refusing to start
6003    /// entirely.
6004    fn validate_secret_handling(&self) -> Result<(), String> {
6005        if let Some(llm) = &self.llm {
6006            // Rejection 1 — inline api_key literal.
6007            if llm.api_key.is_some() {
6008                return Err("inline `api_key = \"<literal>\"` in [llm] is forbidden — \
6009                     use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
6010                     process env var, or `api_key_file = \"/path/to/key\"` to \
6011                     reference a file (mode 0400 enforced). Inline secrets in \
6012                     config.toml (typically world-readable) are a credential \
6013                     leak."
6014                    .to_string());
6015            }
6016            // Rejection 2 — env vs file mutex.
6017            if llm.api_key_env.is_some() && llm.api_key_file.is_some() {
6018                return Err("[llm].api_key_env and [llm].api_key_file are mutually \
6019                     exclusive — set exactly one (or neither, to fall back \
6020                     to the per-vendor env-var chain)."
6021                    .to_string());
6022            }
6023            // Rejection 3 — auto_tag env vs file mutex.
6024            if let Some(auto_tag) = &llm.auto_tag {
6025                if auto_tag.api_key_env.is_some() && auto_tag.api_key_file.is_some() {
6026                    return Err("[llm.auto_tag].api_key_env and \
6027                         [llm.auto_tag].api_key_file are mutually exclusive."
6028                        .to_string());
6029                }
6030            }
6031        }
6032        if let Some(embeddings) = &self.embeddings {
6033            // #1598 Rejection 4 — inline [embeddings].api_key literal
6034            // (mirrors the [llm] rejection above).
6035            if embeddings.api_key.is_some() {
6036                return Err(
6037                    "inline `api_key = \"<literal>\"` in [embeddings] is forbidden — \
6038                     use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
6039                     process env var, or `api_key_file = \"/path/to/key\"` to \
6040                     reference a file (mode 0400 enforced). Inline secrets in \
6041                     config.toml (typically world-readable) are a credential \
6042                     leak."
6043                        .to_string(),
6044                );
6045            }
6046            // #1598 Rejection 5 — [embeddings] env vs file mutex.
6047            if embeddings.api_key_env.is_some() && embeddings.api_key_file.is_some() {
6048                return Err(
6049                    "[embeddings].api_key_env and [embeddings].api_key_file are \
6050                     mutually exclusive — set exactly one (or neither, to fall \
6051                     back to the per-vendor env-var chain)."
6052                        .to_string(),
6053                );
6054            }
6055        }
6056        Ok(())
6057    }
6058
6059    /// L1 fix (v0.7.0): enumerate top-level keys in `contents` and emit a
6060    /// `tracing::warn!` for every key that is not a recognised `AppConfig`
6061    /// field. Malformed TOML is silently skipped here — the existing
6062    /// `toml::from_str::<AppConfig>` parse in `load_from` will surface the
6063    /// real parse error to the operator on the next line.
6064    fn warn_unknown_top_level_keys(path: &Path, contents: &str) {
6065        // Canonical list of `AppConfig` top-level fields. Keep in sync with
6066        // the struct definition above; verified verbatim against the v0.7.0
6067        // L1 spec.
6068        const EXPECTED_KEYS: &[&str] = &[
6069            "tier",
6070            "db",
6071            config_keys::OLLAMA_URL,
6072            "embed_url",
6073            config_keys::EMBEDDING_MODEL,
6074            "llm_model",
6075            config_keys::AUTO_TAG_MODEL,
6076            config_keys::CROSS_ENCODER,
6077            config_keys::DEFAULT_NAMESPACE,
6078            config_keys::MAX_MEMORY_MB,
6079            "ttl",
6080            config_keys::ARCHIVE_ON_GC,
6081            "api_key",
6082            config_keys::ARCHIVE_MAX_DAYS,
6083            "identity",
6084            "scoring",
6085            "autonomous_hooks",
6086            "logging",
6087            "audit",
6088            "boot",
6089            "mcp",
6090            "permissions",
6091            "transcripts",
6092            "hooks",
6093            "subscriptions",
6094            "postgres_statement_timeout_secs",
6095            "postgres_pool_max_connections",
6096            "postgres_pool_min_connections",
6097            "postgres_acquire_timeout_secs",
6098            "request_timeout_secs",
6099            "llm_call_timeout_secs",
6100            "verify",
6101            "mcp_federation_forward_url",
6102            "agents",
6103            "governance",
6104            "confidence",
6105            "admin",
6106            // v0.7.x (#1146) — enterprise configuration sections.
6107            "schema_version",
6108            "llm",
6109            config_keys::SECTION_EMBEDDINGS,
6110            "reranker",
6111            "curator",
6112            "storage",
6113            "limits",
6114        ];
6115
6116        let value: toml::Value = match toml::from_str(contents) {
6117            Ok(v) => v,
6118            // Malformed TOML — defer to the strongly-typed parse in the
6119            // caller, which produces the operator-facing error message.
6120            Err(_) => return,
6121        };
6122
6123        let Some(table) = value.as_table() else {
6124            return;
6125        };
6126
6127        let expected_list = EXPECTED_KEYS.join(", ");
6128        for key in table.keys() {
6129            if !EXPECTED_KEYS.contains(&key.as_str()) {
6130                tracing::warn!(
6131                    "[config] unknown key '{key}' in {path} — top-level AppConfig fields are: {expected_keys}. This key is silently ignored (no behavior change).",
6132                    key = key,
6133                    path = path.display(),
6134                    expected_keys = expected_list,
6135                );
6136            }
6137        }
6138    }
6139
6140    /// v0.7.0 K3 — resolve the effective [`PermissionsMode`] consulted
6141    /// by [`crate::db::enforce_governance`].
6142    ///
6143    /// Resolution order:
6144    /// 1. `AI_MEMORY_PERMISSIONS_MODE` env var (`enforce` /
6145    ///    `advisory` / `off`, case-insensitive). Lets the integration
6146    ///    suite — which sets `AI_MEMORY_NO_CONFIG=1` and therefore
6147    ///    cannot use `[permissions]` from `config.toml` — flip the
6148    ///    gate to Enforce per scenario.
6149    /// 2. `[permissions].mode` from `config.toml`.
6150    /// 3. v0.7.0 secure default ([`PermissionsMode::Enforce`]) when no
6151    ///    explicit configuration is present. Round-2 F8 / Round-3
6152    ///    re-verify: prior to this round the unconfigured fallback was
6153    ///    [`PermissionsMode::default`] (= `advisory`), which left an
6154    ///    upgrading deployment with `metadata.governance.write=owner`
6155    ///    bypassable. We now resolve via
6156    ///    [`crate::permissions::resolve_v07_default_mode`] so every
6157    ///    process-wide entry point (CLI, MCP, HTTP serve) shares the
6158    ///    same secure-by-default posture; operators who want advisory
6159    ///    set `[permissions].mode = "advisory"` explicitly.
6160    #[must_use]
6161    pub fn effective_permissions_mode(&self) -> PermissionsMode {
6162        if let Ok(raw) = std::env::var("AI_MEMORY_PERMISSIONS_MODE") {
6163            match raw.to_ascii_lowercase().as_str() {
6164                "enforce" => return PermissionsMode::Enforce,
6165                "advisory" => return PermissionsMode::Advisory,
6166                "off" => return PermissionsMode::Off,
6167                other => {
6168                    eprintln!(
6169                        "ai-memory: AI_MEMORY_PERMISSIONS_MODE={other:?} is not a valid mode \
6170                         (expected enforce / advisory / off); falling back to config.toml"
6171                    );
6172                }
6173            }
6174        }
6175        // B4 (S5-M3) — both "block absent entirely" and "block present
6176        // but `mode =` omitted" must reach the secure default. The
6177        // `Option<PermissionsMode>` shape lets us collapse both to
6178        // `None` for the resolver so neither path silently inherits
6179        // the serde-derived `Advisory`. The migration WARN that
6180        // `resolve_v07_default_mode` emits when configured is `None`
6181        // is surfaced by the daemon's startup banner
6182        // (see `crate::cli::serve_banner::compose_banner`).
6183        let configured = self.permissions.as_ref().and_then(|p| p.mode);
6184        let (mode, _warn) = crate::permissions::resolve_v07_default_mode(configured);
6185        mode
6186    }
6187
6188    /// v0.7.0 K9 — resolve the effective declarative rule set
6189    /// consulted by [`crate::permissions::Permissions::evaluate`].
6190    ///
6191    /// Returns the rules from `[permissions]` when configured;
6192    /// otherwise an empty vec (no declarative rules — mode + hooks
6193    /// resolve every decision).
6194    #[must_use]
6195    pub fn effective_permission_rules(&self) -> Vec<crate::permissions::PermissionRule> {
6196        self.permissions
6197            .as_ref()
6198            .map(|p| p.rules.clone())
6199            .unwrap_or_default()
6200    }
6201
6202    /// Resolve the effective feature tier from config (CLI flag overrides).
6203    pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
6204        let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
6205        FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
6206    }
6207
6208    /// Resolve the effective database path (CLI flag overrides config).
6209    ///
6210    /// Expands a leading `~` / `~/` in the config-provided path to `$HOME`
6211    /// before returning (issue #507). Without this, `db = "~/.claude/ai-memory.db"`
6212    /// in `config.toml` would land on disk as the literal four-char dir
6213    /// `~/.claude/...` relative to cwd and the daemon would report
6214    /// `warn db unavailable` against the real DB that lives at the
6215    /// expanded path.
6216    pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
6217        // If CLI provided a non-default path, use it
6218        let default_db = PathBuf::from("ai-memory.db");
6219        if cli_db != default_db {
6220            return cli_db.to_path_buf();
6221        }
6222        // Otherwise check config — expanding leading `~` against $HOME.
6223        self.db
6224            .as_ref()
6225            .map_or_else(|| cli_db.to_path_buf(), |s| expand_tilde(s))
6226    }
6227
6228    /// Resolve Ollama URL for LLM generation (config or default).
6229    ///
6230    /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6231    /// New callers should use the sectioned `[llm]` resolver.
6232    #[allow(deprecated)]
6233    pub fn effective_ollama_url(&self) -> &str {
6234        self.ollama_url
6235            .as_deref()
6236            .unwrap_or("http://localhost:11434")
6237    }
6238
6239    /// Resolve TTL configuration from config file, falling back to compiled defaults.
6240    pub fn effective_ttl(&self) -> ResolvedTtl {
6241        ResolvedTtl::from_config(self.ttl.as_ref())
6242    }
6243
6244    /// Resolve recall-scoring configuration (time-decay half-life) from the
6245    /// config file, falling back to compiled defaults. v0.6.0.0.
6246    pub fn effective_scoring(&self) -> ResolvedScoring {
6247        ResolvedScoring::from_config(self.scoring.as_ref())
6248    }
6249
6250    /// Whether to archive memories before GC deletion (default: true).
6251    ///
6252    /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6253    #[allow(deprecated)]
6254    pub fn effective_archive_on_gc(&self) -> bool {
6255        self.archive_on_gc.unwrap_or(true)
6256    }
6257
6258    /// v0.7.0 H7 (round-2) — resolved per-request HTTP timeout.
6259    /// Falls back to [`DEFAULT_REQUEST_TIMEOUT_SECS`] when the
6260    /// `request_timeout_secs` config field is unset.
6261    #[must_use]
6262    pub fn effective_request_timeout_secs(&self) -> u64 {
6263        self.request_timeout_secs
6264            .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS)
6265    }
6266
6267    /// v0.7.0 H8 (round-2) — resolved per-LLM-call timeout. Falls
6268    /// back to [`DEFAULT_LLM_CALL_TIMEOUT_SECS`] when the
6269    /// `llm_call_timeout_secs` config field is unset.
6270    #[must_use]
6271    pub fn effective_llm_call_timeout_secs(&self) -> u64 {
6272        self.llm_call_timeout_secs
6273            .unwrap_or(DEFAULT_LLM_CALL_TIMEOUT_SECS)
6274    }
6275
6276    /// v0.6.4-001 — resolve the effective MCP tool profile.
6277    ///
6278    /// Resolution order:
6279    /// 1. `cli_or_env` (already merged by clap's `#[arg(env="AI_MEMORY_PROFILE")]`)
6280    /// 2. `[mcp].profile` config field
6281    /// 3. compiled default `"core"`
6282    ///
6283    /// # Errors
6284    ///
6285    /// Returns [`crate::profile::ProfileParseError`] if any layer's
6286    /// value is malformed (unknown family or mixed-case token).
6287    pub fn effective_profile(
6288        &self,
6289        cli_or_env: Option<&str>,
6290    ) -> Result<crate::profile::Profile, crate::profile::ProfileParseError> {
6291        let raw = cli_or_env
6292            .or_else(|| self.mcp.as_ref().and_then(|m| m.profile.as_deref()))
6293            .unwrap_or("core");
6294        crate::profile::Profile::parse(raw)
6295    }
6296
6297    /// Whether post-store autonomy hooks (`auto_tag` + `detect_contradiction`)
6298    /// fire on every successful `memory_store`. v0.6.0.0.
6299    /// Precedence: `AI_MEMORY_AUTONOMOUS_HOOKS=1` env var (truthy) >
6300    /// config file > default false. `AI_MEMORY_AUTONOMOUS_HOOKS=0` also
6301    /// honored for explicit-off.
6302    pub fn effective_autonomous_hooks(&self) -> bool {
6303        if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
6304            let v = v.trim().to_ascii_lowercase();
6305            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
6306                return true;
6307            }
6308            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
6309                return false;
6310            }
6311        }
6312        self.autonomous_hooks.unwrap_or(false)
6313    }
6314
6315    /// Whether to anonymize the default `agent_id` fallback (Task 1.2 #198).
6316    /// Precedence: `AI_MEMORY_ANONYMIZE=1` env var (truthy) > config file > default false.
6317    pub fn effective_anonymize_default(&self) -> bool {
6318        if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
6319            let v = v.trim().to_ascii_lowercase();
6320            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
6321                return true;
6322            }
6323            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
6324                return false;
6325            }
6326        }
6327        self.identity.as_ref().is_some_and(|i| i.anonymize_default)
6328    }
6329
6330    /// Resolve the [`LoggingConfig`] block, returning a default
6331    /// (disabled) instance when the config file omits it.
6332    pub fn effective_logging(&self) -> LoggingConfig {
6333        self.logging.clone().unwrap_or_default()
6334    }
6335
6336    /// Resolve the [`AuditConfig`] block, returning a default
6337    /// (disabled) instance when the config file omits it.
6338    pub fn effective_audit(&self) -> AuditConfig {
6339        self.audit.clone().unwrap_or_default()
6340    }
6341
6342    /// v0.7.0 I3 — resolve the [`TranscriptsConfig`] block, returning
6343    /// a default (no namespace overrides → compiled global defaults)
6344    /// instance when the config file omits it.
6345    #[must_use]
6346    pub fn effective_transcripts(&self) -> TranscriptsConfig {
6347        self.transcripts.clone().unwrap_or_default()
6348    }
6349
6350    /// Resolve the [`BootConfig`] block, returning a default
6351    /// (enabled, no redaction) instance when the config file omits
6352    /// it. v0.6.3.1 (PR-9h / issue #487 PR #497 req #73).
6353    pub fn effective_boot(&self) -> BootConfig {
6354        self.boot.clone().unwrap_or_default()
6355    }
6356
6357    /// Resolve URL for embedding model (falls back to `ollama_url`).
6358    ///
6359    /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6360    #[allow(deprecated)]
6361    pub fn effective_embed_url(&self) -> &str {
6362        self.embed_url
6363            .as_deref()
6364            .or(self.ollama_url.as_deref())
6365            .unwrap_or("http://localhost:11434")
6366    }
6367
6368    // ------------------------------------------------------------------
6369    // Canonical resolvers (#1146). Every LLM / embedder / reranker /
6370    // storage surface MUST consume the corresponding `Resolved*` shape
6371    // produced by these methods rather than reading raw config / env
6372    // / tier presets.
6373    //
6374    // Precedence (uniform across all four):
6375    //   CLI flag > AI_MEMORY_* env > config.toml section
6376    //            > legacy flat fields (Legacy source) > compiled default
6377    //
6378    // Resolvers are PURE (no network I/O). `resolve_llm` reads the
6379    // `api_key_file` content at call time if configured; perm checks
6380    // land in a follow-up commit and surface via `KeySource::Error`
6381    // without panicking.
6382    // ------------------------------------------------------------------
6383
6384    /// v0.7.x (#1146) — resolve the canonical LLM configuration.
6385    ///
6386    /// `cli_backend` / `cli_model` / `cli_base_url` carry CLI-flag
6387    /// overrides (pass `None` for `ai-memory mcp` / `ai-memory serve`
6388    /// which currently expose no CLI override; the CLI plumbing lands
6389    /// in a follow-up commit).
6390    ///
6391    /// DOC-6: this resolver intentionally reads the legacy flat
6392    /// fields as the lowest-precedence fallback layer (per the
6393    /// sectioned/v2 contract), so the `#[allow(deprecated)]`
6394    /// attribute is necessary here. External callers should pass
6395    /// CLI / env / `[llm]` section values and let this resolver
6396    /// reach for the legacy fields only when those are unset.
6397    #[must_use]
6398    #[allow(deprecated)]
6399    pub fn resolve_llm(
6400        &self,
6401        cli_backend: Option<&str>,
6402        cli_model: Option<&str>,
6403        cli_base_url: Option<&str>,
6404    ) -> ResolvedLlm {
6405        // ------- 1. backend selection ----------------------------------
6406        let env_backend = std::env::var("AI_MEMORY_LLM_BACKEND")
6407            .ok()
6408            .map(|s| s.trim().to_ascii_lowercase())
6409            .filter(|s| !s.is_empty());
6410        let cfg_backend = self
6411            .llm
6412            .as_ref()
6413            .and_then(|l| l.backend.as_ref())
6414            .map(|s| s.trim().to_ascii_lowercase())
6415            .filter(|s| !s.is_empty());
6416
6417        let (backend, source) = if let Some(b) = cli_backend.map(str::to_ascii_lowercase) {
6418            (b, ConfigSource::Cli)
6419        } else if let Some(b) = env_backend.clone() {
6420            (b, ConfigSource::Env)
6421        } else if let Some(b) = cfg_backend {
6422            (b, ConfigSource::Config)
6423        } else if self.llm_model.is_some() || self.ollama_url.is_some() {
6424            // Legacy flat fields imply Ollama.
6425            ("ollama".to_string(), ConfigSource::Legacy)
6426        } else {
6427            // Compiled default = tier preset (Ollama-native).
6428            ("ollama".to_string(), ConfigSource::CompiledDefault)
6429        };
6430
6431        // ------- 2. model selection ------------------------------------
6432        let model = cli_model
6433            .map(str::to_string)
6434            .filter(|s| !s.trim().is_empty())
6435            .or_else(|| {
6436                std::env::var("AI_MEMORY_LLM_MODEL")
6437                    .ok()
6438                    .filter(|s| !s.trim().is_empty())
6439            })
6440            .or_else(|| {
6441                self.llm
6442                    .as_ref()
6443                    .and_then(|l| l.model.clone())
6444                    .filter(|s| !s.trim().is_empty())
6445            })
6446            .or_else(|| self.llm_model.clone().filter(|s| !s.trim().is_empty()))
6447            .unwrap_or_else(|| backend_default_model(&backend).to_string());
6448
6449        // ------- 3. base_url selection ---------------------------------
6450        let base_url = cli_base_url
6451            .map(str::to_string)
6452            .filter(|s| !s.trim().is_empty())
6453            .or_else(|| {
6454                std::env::var("AI_MEMORY_LLM_BASE_URL")
6455                    .ok()
6456                    .filter(|s| !s.trim().is_empty())
6457            })
6458            .or_else(|| {
6459                self.llm
6460                    .as_ref()
6461                    .and_then(|l| l.base_url.clone())
6462                    .filter(|s| !s.trim().is_empty())
6463            })
6464            .or_else(|| {
6465                if backend == "ollama" {
6466                    self.ollama_url.clone()
6467                } else {
6468                    None
6469                }
6470            })
6471            .unwrap_or_else(|| backend_default_base_url(&backend).to_string());
6472
6473        // ------- 4. api_key selection ----------------------------------
6474        let (api_key, api_key_source) = resolve_api_key(&backend, self.llm.as_ref());
6475
6476        ResolvedLlm {
6477            backend,
6478            model,
6479            base_url,
6480            api_key,
6481            api_key_source,
6482            source,
6483        }
6484    }
6485
6486    /// v0.7.x (#1146) — resolve the `[llm.auto_tag]` fast-structured-
6487    /// output sibling. Fields fall back to [`Self::resolve_llm`] field-
6488    /// by-field; commonly only `model` is overridden (defaults to
6489    /// `gemma3:4b` per the L15 fast-structured-output policy).
6490    ///
6491    /// DOC-6: reads the legacy `auto_tag_model` field as the
6492    /// lowest-precedence fallback layer (`#[allow(deprecated)]`).
6493    #[must_use]
6494    #[allow(deprecated)]
6495    pub fn resolve_llm_auto_tag(&self) -> ResolvedLlm {
6496        let parent = self.resolve_llm(None, None, None);
6497        let sub = self.llm.as_ref().and_then(|l| l.auto_tag.as_ref());
6498
6499        let backend = sub
6500            .and_then(|s| s.backend.clone())
6501            .filter(|s| !s.trim().is_empty())
6502            .unwrap_or_else(|| parent.backend.clone());
6503
6504        let model = sub
6505            .and_then(|s| s.model.clone())
6506            .filter(|s| !s.trim().is_empty())
6507            .or_else(|| self.auto_tag_model.clone().filter(|s| !s.trim().is_empty()))
6508            .unwrap_or_else(|| {
6509                // L15 default: gemma3:4b for fast structured output,
6510                // regardless of parent backend.
6511                if backend == "ollama" {
6512                    "gemma3:4b".to_string()
6513                } else {
6514                    // For non-Ollama backends, use the parent model
6515                    // (no sane way to pick a "fast" model across vendors).
6516                    parent.model.clone()
6517                }
6518            });
6519
6520        let base_url = sub
6521            .and_then(|s| s.base_url.clone())
6522            .filter(|s| !s.trim().is_empty())
6523            .unwrap_or_else(|| {
6524                if backend == parent.backend {
6525                    parent.base_url.clone()
6526                } else {
6527                    backend_default_base_url(&backend).to_string()
6528                }
6529            });
6530
6531        // api_key: inherit from parent if backend matches, else fresh resolve.
6532        let (api_key, api_key_source) = if backend == parent.backend {
6533            (parent.api_key.clone(), parent.api_key_source.clone())
6534        } else {
6535            // Synthesise a transient LlmSection-like view from the sub-table
6536            // for fresh API-key resolution.
6537            let synthetic = sub.map(|s| LlmSection {
6538                backend: Some(backend.clone()),
6539                model: None,
6540                base_url: None,
6541                api_key_env: s.api_key_env.clone(),
6542                api_key_file: s.api_key_file.clone(),
6543                api_key: None,
6544                auto_tag: None,
6545            });
6546            resolve_api_key(&backend, synthetic.as_ref())
6547        };
6548
6549        ResolvedLlm {
6550            backend,
6551            model,
6552            base_url,
6553            api_key,
6554            api_key_source,
6555            source: parent.source,
6556        }
6557    }
6558
6559    /// v0.7.x (#1146) — resolve the canonical embedder configuration.
6560    ///
6561    /// #1598 — extended per-field precedence ladder:
6562    ///
6563    /// - `backend`: `AI_MEMORY_EMBED_BACKEND` env > `[embeddings].backend`
6564    ///   > compiled default (`ollama`).
6565    /// - `url`: `AI_MEMORY_EMBED_BASE_URL` env > `[embeddings].base_url`
6566    ///   > `[embeddings].url` > legacy `embed_url` > legacy `ollama_url`
6567    ///   > the backend alias's default base URL (API backends) > the
6568    ///   localhost Ollama default.
6569    /// - `model`: `AI_MEMORY_EMBED_MODEL` env > `[embeddings].model`
6570    ///   > legacy `embedding_model` > compiled default
6571    ///   (`nomic-embed-text-v1.5`); legacy aliases canonicalised.
6572    /// - `api_key`: [`resolve_embed_api_key`] ladder
6573    ///   (`AI_MEMORY_EMBED_API_KEY` > per-vendor alias env >
6574    ///   `[embeddings].api_key_env` > `[embeddings].api_key_file`).
6575    /// - `embedding_dim`: `[embeddings].dim` override >
6576    ///   [`canonical_embedding_dim`] table > `None`.
6577    ///
6578    /// DOC-6: reads the legacy `embed_url`/`embedding_model`/
6579    /// `ollama_url` fields as the lowest-precedence fallback layer.
6580    #[must_use]
6581    #[allow(deprecated)]
6582    pub fn resolve_embeddings(&self) -> ResolvedEmbeddings {
6583        let cfg = self.embeddings.as_ref();
6584
6585        let env_backend = std::env::var(ENV_EMBED_BACKEND)
6586            .ok()
6587            .map(|s| s.trim().to_ascii_lowercase())
6588            .filter(|s| !s.is_empty());
6589        let backend = env_backend
6590            .clone()
6591            .or_else(|| {
6592                cfg.and_then(|e| e.backend.as_ref())
6593                    .map(|s| s.trim().to_ascii_lowercase())
6594                    .filter(|s| !s.is_empty())
6595            })
6596            .unwrap_or_else(|| crate::llm::BACKEND_OLLAMA.to_string());
6597
6598        let url = std::env::var(ENV_EMBED_BASE_URL)
6599            .ok()
6600            .filter(|s| !s.trim().is_empty())
6601            .or_else(|| {
6602                cfg.and_then(|e| e.base_url.clone())
6603                    .filter(|s| !s.trim().is_empty())
6604            })
6605            .or_else(|| {
6606                cfg.and_then(|e| e.url.clone())
6607                    .filter(|s| !s.trim().is_empty())
6608            })
6609            .or_else(|| self.embed_url.clone().filter(|s| !s.trim().is_empty()))
6610            .or_else(|| self.ollama_url.clone().filter(|s| !s.trim().is_empty()))
6611            .or_else(|| {
6612                // #1598 — API backends default to the vendor's base URL
6613                // (declared once in llm.rs); `openai-compatible` has no
6614                // sane default and falls through.
6615                if is_api_embed_backend(&backend) {
6616                    crate::llm::default_base_url_for_alias(&backend).map(str::to_string)
6617                } else {
6618                    None
6619                }
6620            })
6621            .unwrap_or_else(|| crate::llm::DEFAULT_OLLAMA_URL.to_string());
6622
6623        let model = std::env::var(ENV_EMBED_MODEL)
6624            .ok()
6625            .filter(|s| !s.trim().is_empty())
6626            .or_else(|| {
6627                cfg.and_then(|e| e.model.clone())
6628                    .filter(|s| !s.trim().is_empty())
6629            })
6630            .or_else(|| {
6631                self.embedding_model
6632                    .clone()
6633                    .filter(|s| !s.trim().is_empty())
6634            })
6635            .map(canonicalise_embedding_model)
6636            .unwrap_or_else(|| DEFAULT_EMBED_MODEL.to_string());
6637
6638        let backfill_batch_env = std::env::var(ENV_EMBED_BACKFILL_BATCH)
6639            .ok()
6640            .and_then(|s| s.trim().parse::<u32>().ok());
6641        let backfill_batch_cfg = cfg.and_then(|e| e.backfill_batch);
6642        let backfill_batch_raw = backfill_batch_env.or(backfill_batch_cfg);
6643        let backfill_batch = match backfill_batch_raw {
6644            Some(n) if (1..=10000).contains(&n) => n,
6645            // #1649 — out-of-range values were silently swallowed while
6646            // the env-var table promised a warn-log (the sibling knob
6647            // AI_MEMORY_WEBHOOK_DISPATCH_CONCURRENCY already warns).
6648            Some(n) => {
6649                tracing::warn!(
6650                    "{ENV_EMBED_BACKFILL_BATCH}={n} outside 1..=10000 — falling back to default {DEFAULT_EMBED_BACKFILL_BATCH}"
6651                );
6652                DEFAULT_EMBED_BACKFILL_BATCH
6653            }
6654            None => DEFAULT_EMBED_BACKFILL_BATCH,
6655        };
6656
6657        let source = if env_backend.is_some() {
6658            ConfigSource::Env
6659        } else if cfg.is_some() {
6660            ConfigSource::Config
6661        } else if self.embed_url.is_some()
6662            || self.embedding_model.is_some()
6663            || self.ollama_url.is_some()
6664        {
6665            ConfigSource::Legacy
6666        } else {
6667            ConfigSource::CompiledDefault
6668        };
6669
6670        // v0.7.x (#1169) — derive the dim from the resolved model id
6671        // via the canonical lookup table. #1598 — the explicit
6672        // `[embeddings].dim` override wins (escape hatch for models
6673        // not in [`KNOWN_EMBEDDING_DIMS`]); non-positive overrides are
6674        // ignored. None when neither layer knows the dim; callers
6675        // (capabilities surface) fall back to the tier preset's
6676        // compiled dim.
6677        let embedding_dim = cfg
6678            .and_then(|e| e.dim)
6679            .filter(|d| *d > 0)
6680            .or_else(|| canonical_embedding_dim(&model));
6681
6682        // #1598 (fleet follow-up) — the EXPLICIT override alone also
6683        // becomes the wire `dimensions` request for OpenAI-compatible
6684        // backends (Matryoshka truncation; see
6685        // [`ResolvedEmbeddings::requested_dim`]). Deliberately NOT
6686        // populated from the table lookup — a table dim describes the
6687        // model's native output and must not be re-requested.
6688        let requested_dim = cfg.and_then(|e| e.dim).filter(|d| *d > 0);
6689
6690        // #1598 — embedding API key (None for ollama / keyless
6691        // self-hosted endpoints).
6692        let (api_key, key_source) = resolve_embed_api_key(&backend, cfg);
6693
6694        ResolvedEmbeddings {
6695            backend,
6696            url,
6697            model,
6698            backfill_batch,
6699            embedding_dim,
6700            requested_dim,
6701            api_key,
6702            key_source,
6703            source,
6704        }
6705    }
6706
6707    /// v0.7.x (#1146) — resolve the canonical reranker configuration.
6708    /// Folds the legacy `cross_encoder: Option<bool>` flag into the
6709    /// `enabled` field; `model` defaults to `ms-marco-MiniLM-L-6-v2`.
6710    ///
6711    /// DOC-6: reads the legacy `cross_encoder` field as the
6712    /// lowest-precedence fallback layer.
6713    #[must_use]
6714    #[allow(deprecated)]
6715    pub fn resolve_reranker(&self) -> ResolvedReranker {
6716        let cfg = self.reranker.as_ref();
6717
6718        let enabled = cfg
6719            .and_then(|r| r.enabled)
6720            .or(self.cross_encoder)
6721            // Default reranker-on for the autonomous tier; off otherwise.
6722            // Boot wires the actual tier-default at the resolver call
6723            // site (it's already keyed off `tier_config.cross_encoder`).
6724            .unwrap_or(false);
6725
6726        let model = cfg
6727            .and_then(|r| r.model.clone())
6728            .filter(|s| !s.trim().is_empty())
6729            .unwrap_or_else(|| "ms-marco-MiniLM-L-6-v2".to_string());
6730
6731        // #1604 — rerank input sequence cap, uniform ladder:
6732        // env > [reranker] section > compiled default. Zero,
6733        // unparseable, or above-model-ceiling values fall through.
6734        let admissible = |n: &usize| *n > 0 && *n <= crate::reranker::CROSS_ENCODER_MAX_SEQ;
6735        let max_seq_tokens = std::env::var(ENV_RERANK_MAX_SEQ)
6736            .ok()
6737            .and_then(|s| s.trim().parse::<usize>().ok())
6738            .filter(admissible)
6739            .or_else(|| cfg.and_then(|r| r.max_seq_tokens).filter(admissible))
6740            .unwrap_or(crate::reranker::RERANK_MAX_SEQ_DEFAULT);
6741
6742        let source = if cfg.is_some() {
6743            ConfigSource::Config
6744        } else if self.cross_encoder.is_some() {
6745            ConfigSource::Legacy
6746        } else {
6747            ConfigSource::CompiledDefault
6748        };
6749
6750        ResolvedReranker {
6751            enabled,
6752            model,
6753            max_seq_tokens,
6754            source,
6755        }
6756    }
6757
6758    /// #1691/n14 — resolve the recall-reranker score floor. Uniform
6759    /// ladder: `AI_MEMORY_RERANK_SCORE_FLOOR` env > `[reranker].score_floor`
6760    /// config > compiled default ([`crate::reranker::RerankerScoreFloor::Off`]).
6761    /// Unparseable values at any layer fall through to the next.
6762    ///
6763    /// Kept as a dedicated resolver (rather than a field on
6764    /// [`ResolvedReranker`]) because [`crate::reranker::RerankerScoreFloor`]
6765    /// carries an `f64` and is therefore `PartialEq`-only, while
6766    /// `ResolvedReranker` / `ResolvedModels` derive `Eq`. Fed to
6767    /// [`crate::reranker::BatchedReranker::with_score_floor`] at the
6768    /// `serve` and `mcp` reranker build sites so the score-floor
6769    /// capability is finally operator-reachable (it was dead config
6770    /// before #1691/n14).
6771    #[must_use]
6772    pub fn resolve_reranker_score_floor(&self) -> crate::reranker::RerankerScoreFloor {
6773        std::env::var(ENV_RERANK_SCORE_FLOOR)
6774            .ok()
6775            .as_deref()
6776            .and_then(crate::reranker::RerankerScoreFloor::parse)
6777            .or_else(|| {
6778                self.reranker
6779                    .as_ref()
6780                    .and_then(|r| r.score_floor.as_deref())
6781                    .and_then(crate::reranker::RerankerScoreFloor::parse)
6782            })
6783            .unwrap_or(crate::reranker::RerankerScoreFloor::Off)
6784    }
6785
6786    /// #1671 — whether `curator --reflect --all-namespaces` should
6787    /// reflect `namespace`. True ONLY when
6788    /// `[curator.reflection_namespaces."<ns>"]` exists with
6789    /// `enabled = true`. The conservative default is `false` (no config
6790    /// → no fan-out), matching the pre-#1671 inert-but-safe posture where
6791    /// `--all-namespaces` reflected nothing. A single `--namespace <ns>`
6792    /// invocation bypasses this gate at the call site.
6793    #[must_use]
6794    pub fn reflection_namespace_enabled(&self, namespace: &str) -> bool {
6795        self.curator
6796            .as_ref()
6797            .and_then(|c| c.reflection_namespaces.as_ref())
6798            .and_then(|m| m.get(namespace))
6799            .is_some_and(|cfg| cfg.enabled)
6800    }
6801
6802    /// n15 — resolve the confidence-decay half-life (days) for
6803    /// `namespace`: `[curator.confidence_decay_half_life_days."<ns>"]`
6804    /// when present, finite, and `> 0`, else the compiled
6805    /// [`crate::confidence::DEFAULT_HALF_LIFE_DAYS`].
6806    #[must_use]
6807    pub fn confidence_decay_half_life_for(&self, namespace: &str) -> f64 {
6808        self.curator
6809            .as_ref()
6810            .and_then(|c| c.confidence_decay_half_life_days.as_ref())
6811            .and_then(|m| m.get(namespace))
6812            .copied()
6813            .filter(|v| v.is_finite() && *v > 0.0)
6814            .unwrap_or(crate::confidence::DEFAULT_HALF_LIFE_DAYS)
6815    }
6816
6817    /// n15 — snapshot the per-namespace confidence-decay half-life
6818    /// overrides for boot-time seeding into the process-global resolver
6819    /// ([`crate::confidence::decay::set_namespace_half_life_overrides`]),
6820    /// keeping only finite, positive values. Empty when no `[curator]`
6821    /// overrides are configured.
6822    #[must_use]
6823    pub fn confidence_decay_half_life_overrides(&self) -> std::collections::HashMap<String, f64> {
6824        self.curator
6825            .as_ref()
6826            .and_then(|c| c.confidence_decay_half_life_days.as_ref())
6827            .map(|m| {
6828                m.iter()
6829                    .filter(|(_, v)| v.is_finite() && **v > 0.0)
6830                    .map(|(k, v)| (k.clone(), *v))
6831                    .collect()
6832            })
6833            .unwrap_or_default()
6834    }
6835
6836    /// v0.7.x (issue #1168) — bundle the three model-resolver outputs
6837    /// into a single [`ResolvedModels`] triple for the capabilities
6838    /// surface (MCP `memory_capabilities`, HTTP `GET /api/v1/capabilities`).
6839    ///
6840    /// Routes through the canonical [`Self::resolve_llm`],
6841    /// [`Self::resolve_embeddings`], and [`Self::resolve_reranker`]
6842    /// resolvers so the capabilities `models.*` block reflects the
6843    /// same resolved configuration the live LLM client / embedder /
6844    /// reranker were built from, NEVER the compiled tier preset.
6845    ///
6846    /// Pairs with [`ResolvedModels::from_tier_preset`] (back-compat
6847    /// constructor for tests that scaffold a `TierConfig` without an
6848    /// `AppConfig`).
6849    #[must_use]
6850    pub fn resolve_models(&self) -> ResolvedModels {
6851        ResolvedModels {
6852            llm: self.resolve_llm(None, None, None),
6853            embeddings: self.resolve_embeddings(),
6854            reranker: self.resolve_reranker(),
6855        }
6856    }
6857
6858    /// v0.7.x (#1146) — resolve the canonical storage configuration.
6859    ///
6860    /// DOC-6: reads the legacy `default_namespace`/`archive_on_gc`/
6861    /// `archive_max_days`/`max_memory_mb` fields as the
6862    /// lowest-precedence fallback layer.
6863    #[must_use]
6864    #[allow(deprecated)]
6865    pub fn resolve_storage(&self) -> ResolvedStorage {
6866        let cfg = self.storage.as_ref();
6867
6868        // #1590 — track WHICH layer supplied `default_namespace` so
6869        // write-path consumers can distinguish an explicit operator
6870        // choice from the compiled fallback (only the former overrides
6871        // the historical per-surface defaults).
6872        let section_ns = cfg
6873            .and_then(|s| s.default_namespace.clone())
6874            .filter(|s| !s.trim().is_empty());
6875        let legacy_ns = self
6876            .default_namespace
6877            .clone()
6878            .filter(|s| !s.trim().is_empty());
6879        let default_namespace_source = if section_ns.is_some() {
6880            ConfigSource::Config
6881        } else if legacy_ns.is_some() {
6882            ConfigSource::Legacy
6883        } else {
6884            ConfigSource::CompiledDefault
6885        };
6886        let default_namespace = section_ns
6887            .or(legacy_ns)
6888            .unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string());
6889
6890        let archive_on_gc = cfg
6891            .and_then(|s| s.archive_on_gc)
6892            .or(self.archive_on_gc)
6893            .unwrap_or(true);
6894
6895        let archive_max_days = cfg
6896            .and_then(|s| s.archive_max_days)
6897            .or(self.archive_max_days);
6898
6899        let max_memory_mb = cfg.and_then(|s| s.max_memory_mb).or(self.max_memory_mb);
6900
6901        // #1579 B7 — sqlite mmap size, uniform ladder:
6902        // env > [storage] section > compiled default. `0` is a
6903        // deliberate operator choice (disable mmap) so the filter
6904        // admits it; negative / unparseable values fall through.
6905        let db_mmap_size_bytes = std::env::var(ENV_DB_MMAP_SIZE)
6906            .ok()
6907            .and_then(|s| s.trim().parse::<i64>().ok())
6908            .filter(|n| *n >= 0)
6909            .or_else(|| cfg.and_then(|s| s.db_mmap_size_bytes).filter(|n| *n >= 0))
6910            .unwrap_or(crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES);
6911
6912        let source = if cfg.is_some() {
6913            ConfigSource::Config
6914        } else if self.default_namespace.is_some()
6915            || self.archive_on_gc.is_some()
6916            || self.archive_max_days.is_some()
6917            || self.max_memory_mb.is_some()
6918        {
6919            ConfigSource::Legacy
6920        } else {
6921            ConfigSource::CompiledDefault
6922        };
6923
6924        ResolvedStorage {
6925            default_namespace,
6926            archive_on_gc,
6927            archive_max_days,
6928            max_memory_mb,
6929            db_mmap_size_bytes,
6930            default_namespace_source,
6931            source,
6932        }
6933    }
6934
6935    /// v0.7.x — resolve the operator-tunable capacity limits.
6936    ///
6937    /// Precedence ladder per field (highest wins):
6938    /// `AI_MEMORY_MAX_*` env > `[limits]` section > compiled default.
6939    /// Non-positive values (≤ 0) at any layer are treated as "unset" so
6940    /// a stray `0` never silently disables writes — the next layer down
6941    /// is consulted instead. The compiled defaults are the named
6942    /// `crate::quotas::DEFAULT_MAX_*` constants and
6943    /// [`crate::handlers::MAX_BULK_SIZE`]; no numeric literals live in
6944    /// this resolver.
6945    #[must_use]
6946    pub fn resolve_limits(&self) -> ResolvedLimits {
6947        let cfg = self.limits.as_ref();
6948
6949        fn env_pos_i64(name: &str) -> Option<i64> {
6950            std::env::var(name)
6951                .ok()
6952                .and_then(|s| s.trim().parse::<i64>().ok())
6953                .filter(|n| *n > 0)
6954        }
6955        fn env_pos_usize(name: &str) -> Option<usize> {
6956            std::env::var(name)
6957                .ok()
6958                .and_then(|s| s.trim().parse::<usize>().ok())
6959                .filter(|n| *n > 0)
6960        }
6961
6962        let mem_env = env_pos_i64(ENV_MAX_MEMORIES_PER_DAY);
6963        let mem_cfg = cfg.and_then(|l| l.max_memories_per_day).filter(|n| *n > 0);
6964        let max_memories_per_day = mem_env
6965            .or(mem_cfg)
6966            .unwrap_or(crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY);
6967
6968        let bytes_env = env_pos_i64(ENV_MAX_STORAGE_BYTES);
6969        let bytes_cfg = cfg.and_then(|l| l.max_storage_bytes).filter(|n| *n > 0);
6970        let max_storage_bytes = bytes_env
6971            .or(bytes_cfg)
6972            .unwrap_or(crate::quotas::DEFAULT_MAX_STORAGE_BYTES);
6973
6974        let links_env = env_pos_i64(ENV_MAX_LINKS_PER_DAY);
6975        let links_cfg = cfg.and_then(|l| l.max_links_per_day).filter(|n| *n > 0);
6976        let max_links_per_day = links_env
6977            .or(links_cfg)
6978            .unwrap_or(crate::quotas::DEFAULT_MAX_LINKS_PER_DAY);
6979
6980        let page_env = env_pos_usize(ENV_MAX_PAGE_SIZE);
6981        let page_cfg = cfg.and_then(|l| l.max_page_size).filter(|n| *n > 0);
6982        let max_page_size = page_env
6983            .or(page_cfg)
6984            .unwrap_or(crate::handlers::MAX_BULK_SIZE);
6985
6986        let source = if mem_env.is_some()
6987            || bytes_env.is_some()
6988            || links_env.is_some()
6989            || page_env.is_some()
6990        {
6991            ConfigSource::Env
6992        } else if mem_cfg.is_some()
6993            || bytes_cfg.is_some()
6994            || links_cfg.is_some()
6995            || page_cfg.is_some()
6996        {
6997            ConfigSource::Config
6998        } else {
6999            ConfigSource::CompiledDefault
7000        };
7001
7002        ResolvedLimits {
7003            max_memories_per_day,
7004            max_storage_bytes,
7005            max_links_per_day,
7006            max_page_size,
7007            source,
7008        }
7009    }
7010
7011    /// Resolve the Postgres connection-pool sizing knobs into a
7012    /// [`crate::store::PoolConfig`] for the daemon's `build_store_handle`.
7013    ///
7014    /// Follows the uniform precedence ladder, per field:
7015    ///
7016    /// ```text
7017    /// AI_MEMORY_PG_POOL_MAX / _MIN / _ACQUIRE_TIMEOUT_SECS env
7018    ///   > top-level config.toml field
7019    ///   > compiled default (PoolConfig::default())
7020    /// ```
7021    ///
7022    /// Mirrors [`Self::resolve_limits`]: any non-positive or unparseable
7023    /// value is filtered so it falls through to the next layer (a stray
7024    /// `0` `max_connections` can never collapse the pool to unusable).
7025    #[cfg(feature = "sal")]
7026    #[must_use]
7027    pub fn resolve_pg_pool(&self) -> crate::store::PoolConfig {
7028        fn env_pos_u32(name: &str) -> Option<u32> {
7029            std::env::var(name)
7030                .ok()
7031                .and_then(|s| s.trim().parse::<u32>().ok())
7032                .filter(|n| *n > 0)
7033        }
7034        fn env_pos_u64(name: &str) -> Option<u64> {
7035            std::env::var(name)
7036                .ok()
7037                .and_then(|s| s.trim().parse::<u64>().ok())
7038                .filter(|n| *n > 0)
7039        }
7040
7041        let defaults = crate::store::PoolConfig::default();
7042
7043        let max_connections = env_pos_u32(ENV_PG_POOL_MAX)
7044            .or_else(|| self.postgres_pool_max_connections.filter(|n| *n > 0))
7045            .unwrap_or(defaults.max_connections);
7046
7047        let min_connections = env_pos_u32(ENV_PG_POOL_MIN)
7048            .or_else(|| self.postgres_pool_min_connections.filter(|n| *n > 0))
7049            .unwrap_or(defaults.min_connections);
7050
7051        let acquire_timeout_secs = env_pos_u64(ENV_PG_ACQUIRE_TIMEOUT_SECS)
7052            .or_else(|| self.postgres_acquire_timeout_secs.filter(|n| *n > 0))
7053            .unwrap_or(defaults.acquire_timeout_secs);
7054
7055        crate::store::PoolConfig {
7056            max_connections,
7057            min_connections,
7058            acquire_timeout_secs,
7059        }
7060    }
7061
7062    /// Write a default config file if one doesn't exist yet.
7063    pub fn write_default_if_missing() {
7064        let Some(path) = Self::config_path() else {
7065            return;
7066        };
7067        if path.exists() {
7068            return;
7069        }
7070        if let Some(parent) = path.parent() {
7071            let _ = std::fs::create_dir_all(parent);
7072        }
7073        let default_toml = r#"# ai-memory configuration
7074# See: https://github.com/alphaonedev/ai-memory-mcp
7075
7076# Feature tier: keyword, semantic, smart, autonomous
7077# tier = "semantic"
7078
7079# Path to SQLite database
7080# db = "~/.claude/ai-memory.db"
7081
7082# Ollama base URL (for smart/autonomous tiers)
7083# ollama_url = "http://localhost:11434"
7084
7085# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
7086# embedding_model = "mini_lm_l6_v2"
7087
7088# LLM model tag for Ollama
7089# llm_model = "gemma4:e2b"
7090
7091# Dedicated model for auto_tag (short structured output).
7092# Defaults to gemma3:4b. Reasoning-heavy features still use llm_model.
7093# auto_tag_model = "gemma3:4b"
7094
7095# Enable neural cross-encoder reranking (autonomous tier)
7096# cross_encoder = true
7097
7098# Default namespace for new memories
7099# default_namespace = "global"
7100
7101# Memory budget in MB (for auto tier selection)
7102# max_memory_mb = 4096
7103
7104# Archive expired memories before GC deletion (default: true)
7105# archive_on_gc = true
7106
7107# Postgres connection-pool sizing (postgres store only; sqlite ignores).
7108# Precedence per field: AI_MEMORY_PG_POOL_MAX / _MIN /
7109# _ACQUIRE_TIMEOUT_SECS env > these fields > compiled default.
7110# Non-positive / unparseable values fall through to the default.
7111# postgres_pool_max_connections = 16        # hard ceiling on open connections
7112# postgres_pool_min_connections = 2         # always-open warm-connection floor
7113# postgres_acquire_timeout_secs = 30        # acquire() wait before erroring (secs)
7114
7115# Per-tier TTL overrides (uncomment to customize)
7116# [ttl]
7117# short_ttl_secs = 21600        # 6 hours (default)
7118# mid_ttl_secs = 604800         # 7 days (default)
7119# long_ttl_secs = 0             # 0 = never expires (default)
7120# short_extend_secs = 3600      # +1h on access (default)
7121# mid_extend_secs = 86400       # +1d on access (default)
7122
7123# v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
7124# Default-OFF. Uncomment + set enabled = true to capture every
7125# `tracing::*` call site to a rotating on-disk log file. See
7126# `docs/security/audit-trail.md` §SIEM ingestion guide for Splunk /
7127# Datadog / Elastic / Loki recipes.
7128# [logging]
7129# enabled = false
7130# path = "~/.local/state/ai-memory/logs/"
7131# max_size_mb = 100
7132# max_files = 30
7133# retention_days = 90
7134# structured = false              # true = emit JSON lines for SIEM ingest
7135# level = "info"                  # tracing EnvFilter directive
7136# rotation = "daily"              # minutely | hourly | daily | never
7137
7138# v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF.
7139# When enabled, every memory mutation emits one hash-chained JSON
7140# line per event suitable for SOC2 / HIPAA / GDPR / FedRAMP evidence.
7141# `ai-memory audit verify` walks the chain; `ai-memory logs tail`
7142# streams events.
7143# [audit]
7144# enabled = false
7145# path = "~/.local/state/ai-memory/audit/"
7146# schema_version = 1
7147# redact_content = true            # v1 schema never emits content; reserved
7148# hash_chain = true
7149# attestation_cadence_minutes = 60
7150# append_only = true               # best-effort chflags(2) / FS_IOC_SETFLAGS
7151
7152# Compliance presets. Set `applied = true` and the documented retention
7153# / cadence values override the defaults above. See
7154# `docs/security/audit-trail.md` §Compliance.
7155# [audit.compliance.soc2]
7156# applied = false
7157# retention_days = 730
7158# redact_content = true
7159# attestation_cadence_minutes = 60
7160#
7161# [audit.compliance.hipaa]
7162# applied = false
7163# retention_days = 2190
7164# redact_content = true
7165# encrypt_at_rest = true           # pair with --features sqlcipher
7166#
7167# [audit.compliance.gdpr]
7168# applied = false
7169# retention_days = 1095
7170# redact_content = true
7171# pseudonymize_actors = true       # reserved for v0.7+
7172#
7173# [audit.compliance.fedramp]
7174# applied = false
7175# retention_days = 1095
7176# redact_content = true
7177# attestation_cadence_minutes = 30
7178
7179# v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy controls.
7180# Default-ON (omit the section entirely for the historical pre-v0.6.3.1
7181# behavior). Two knobs:
7182#
7183# - `enabled = false` silences `ai-memory boot` entirely: empty stdout,
7184#   empty stderr, exit 0. The SessionStart hook injects nothing. Use on
7185#   privacy-sensitive hosts where memory titles must never enter CI
7186#   logs. The env var `AI_MEMORY_BOOT_ENABLED=0` takes precedence over
7187#   this config (same precedence pattern as PR-5's log-dir resolution).
7188#
7189# - `redact_titles = true` keeps the manifest header but replaces row
7190#   `title` fields with `<redacted>` — useful for compliance contexts
7191#   that need the audit-trail signal of "boot ran with N memories"
7192#   without exposing memory subjects.
7193# [boot]
7194# enabled = true
7195# redact_titles = false
7196"#;
7197        let _ = std::fs::write(&path, default_toml);
7198    }
7199}
7200
7201// ---------------------------------------------------------------------------
7202// Tests
7203// ---------------------------------------------------------------------------
7204
7205#[cfg(test)]
7206#[allow(deprecated)] // DOC-6: tests intentionally exercise legacy AppConfig flat fields
7207mod tests {
7208    use super::*;
7209
7210    /// M9 — process-wide guard around every test that calls
7211    /// `std::env::set_var` / `std::env::remove_var`. Test binaries run
7212    /// in parallel by default (`cargo test --jobs N`); env mutation is
7213    /// process-global so two scenarios touching the same key race
7214    /// non-deterministically. Every test in this module that flips an
7215    /// env var MUST hold this mutex for the duration of its body.
7216    ///
7217    /// Poison-OK: a panicking scenario that drops the guard mid-mutation
7218    /// still hands the next caller a usable lock. Subsequent tests
7219    /// re-establish the env state they need on entry.
7220    fn env_var_lock() -> std::sync::MutexGuard<'static, ()> {
7221        use std::sync::{Mutex, OnceLock};
7222        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
7223        LOCK.get_or_init(|| Mutex::new(()))
7224            .lock()
7225            .unwrap_or_else(std::sync::PoisonError::into_inner)
7226    }
7227
7228    #[test]
7229    fn tier_roundtrip() {
7230        for tier in [
7231            FeatureTier::Keyword,
7232            FeatureTier::Semantic,
7233            FeatureTier::Smart,
7234            FeatureTier::Autonomous,
7235        ] {
7236            assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
7237        }
7238    }
7239
7240    #[test]
7241    fn budget_selection() {
7242        assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
7243        assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
7244        assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
7245        assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
7246        assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
7247        assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
7248        assert_eq!(
7249            FeatureTier::from_memory_budget(4096),
7250            FeatureTier::Autonomous
7251        );
7252        assert_eq!(
7253            FeatureTier::from_memory_budget(8192),
7254            FeatureTier::Autonomous
7255        );
7256    }
7257
7258    #[test]
7259    fn embedding_dimensions() {
7260        assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
7261        assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
7262    }
7263
7264    /// L2 fix — `AppConfig.embedding_model` is an `Option<String>` we
7265    /// must parse before handing it to `build_embedder`. This test
7266    /// pins the wire form (snake_case, matches serde rename_all),
7267    /// confirms case-insensitive + trim-tolerant parsing, and that
7268    /// garbage input produces an actionable Err rather than panicking.
7269    #[test]
7270    fn embedding_model_from_str() {
7271        use std::str::FromStr;
7272        assert_eq!(
7273            EmbeddingModel::from_str("mini_lm_l6_v2").unwrap(),
7274            EmbeddingModel::MiniLmL6V2
7275        );
7276        assert_eq!(
7277            EmbeddingModel::from_str("nomic_embed_v15").unwrap(),
7278            EmbeddingModel::NomicEmbedV15
7279        );
7280        // Case-insensitive: operators copy/paste from docs in any case.
7281        assert_eq!(
7282            EmbeddingModel::from_str("MINI_LM_L6_V2").unwrap(),
7283            EmbeddingModel::MiniLmL6V2
7284        );
7285        assert_eq!(
7286            EmbeddingModel::from_str("Nomic_Embed_V15").unwrap(),
7287            EmbeddingModel::NomicEmbedV15
7288        );
7289        // Trim whitespace — common TOML editing artifact.
7290        assert_eq!(
7291            EmbeddingModel::from_str("  mini_lm_l6_v2  ").unwrap(),
7292            EmbeddingModel::MiniLmL6V2
7293        );
7294        // Invalid input -> Err with a useful message naming the bad value.
7295        let err = EmbeddingModel::from_str("garbage").unwrap_err();
7296        assert!(err.contains("garbage"), "err message lost the input: {err}");
7297        assert!(
7298            err.contains("mini_lm_l6_v2") && err.contains("nomic_embed_v15"),
7299            "err message should list valid options: {err}"
7300        );
7301    }
7302
7303    /// #1521 — `from_canonical_id` must accept every form an operator
7304    /// might write in `[embeddings].model`: the snake wire form, the HF
7305    /// id (the `canonicalise_embedding_model` output), the unprefixed
7306    /// shortname, and the Ollama tag. This is what lets the sectioned
7307    /// config block drive the daemon embedder.
7308    #[test]
7309    fn embedding_model_from_canonical_id_accepts_all_forms() {
7310        // nomic family — snake, canonical HF id, Ollama tag, prefixed id.
7311        for id in [
7312            "nomic_embed_v15",
7313            "nomic-embed-text-v1.5",
7314            "nomic-embed-text",
7315            "nomic-ai/nomic-embed-text-v1.5",
7316        ] {
7317            assert_eq!(
7318                EmbeddingModel::from_canonical_id(id),
7319                Some(EmbeddingModel::NomicEmbedV15),
7320                "nomic alias {id:?} must resolve"
7321            );
7322        }
7323        // MiniLM family — snake, canonical HF id, shortname, Ollama tag.
7324        for id in [
7325            "mini_lm_l6_v2",
7326            "sentence-transformers/all-MiniLM-L6-v2",
7327            "all-MiniLM-L6-v2",
7328            "all-minilm",
7329        ] {
7330            assert_eq!(
7331                EmbeddingModel::from_canonical_id(id),
7332                Some(EmbeddingModel::MiniLmL6V2),
7333                "minilm alias {id:?} must resolve"
7334            );
7335        }
7336        // The canonicalised output of a legacy alias must round-trip.
7337        assert_eq!(
7338            EmbeddingModel::from_canonical_id(&canonicalise_embedding_model(
7339                "nomic_embed_v15".to_string()
7340            )),
7341            Some(EmbeddingModel::NomicEmbedV15)
7342        );
7343        // Case-insensitive + whitespace-trimmed.
7344        assert_eq!(
7345            EmbeddingModel::from_canonical_id("  NOMIC-EMBED-TEXT-V1.5  "),
7346            Some(EmbeddingModel::NomicEmbedV15)
7347        );
7348        // Models the 2-model daemon embedder cannot construct → None
7349        // (caller falls back to the tier preset), and empty → None.
7350        assert_eq!(EmbeddingModel::from_canonical_id("bge-large-en"), None);
7351        assert_eq!(EmbeddingModel::from_canonical_id("mxbai-embed-large"), None);
7352        assert_eq!(EmbeddingModel::from_canonical_id(""), None);
7353        assert_eq!(EmbeddingModel::from_canonical_id("   "), None);
7354    }
7355
7356    #[test]
7357    fn autonomous_has_cross_encoder() {
7358        let cfg = FeatureTier::Autonomous.config();
7359        assert!(cfg.cross_encoder);
7360        let caps = cfg.capabilities();
7361        assert!(caps.features.cross_encoder_reranking);
7362        // v0.7.0 recursive-learning (issue #655): Tasks 1-6 shipped
7363        // the primitive, so the planned-feature object is now
7364        // `planned=false, enabled=true, version="v0.7.0"`. The
7365        // pre-v0.6.3.1 honesty contract still uses the
7366        // `PlannedFeature` shape so the v1 bool projection
7367        // collapses cleanly back to `true`.
7368        assert!(!caps.features.memory_reflection.planned);
7369        assert!(caps.features.memory_reflection.enabled);
7370        assert_eq!(caps.features.memory_reflection.version, "v0.7.0");
7371    }
7372
7373    #[test]
7374    fn keyword_has_no_models() {
7375        let cfg = FeatureTier::Keyword.config();
7376        assert!(cfg.embedding_model.is_none());
7377        assert!(cfg.llm_model.is_none());
7378        assert!(!cfg.cross_encoder);
7379        assert_eq!(cfg.max_memory_mb, 0);
7380    }
7381
7382    #[test]
7383    fn capabilities_serialize() {
7384        let caps = FeatureTier::Smart.config().capabilities();
7385        let json = serde_json::to_string_pretty(&caps).unwrap();
7386        assert!(json.contains("\"tier\": \"smart\""));
7387        assert!(json.contains("nomic"));
7388        // The smart tier surfaces the provider-agnostic compiled default
7389        // model tag — asserted against the single source of truth, not a
7390        // copied literal, so no vendor/model string is pinned in the test.
7391        assert!(json.contains(default_tier_llm_model()));
7392    }
7393
7394    /// v0.6.3.1 (capabilities schema v2, P1 honesty patch).
7395    /// Round-trip the new struct through serde_json and assert the v2
7396    /// honesty contract: dropped fields absent, planned-feature blocks
7397    /// shaped correctly, runtime-state defaults conservative.
7398    #[test]
7399    fn capabilities_v2_zero_state_round_trip() {
7400        let _gate = lock_permissions_mode_for_test();
7401        // K3 default is `advisory` — clear any override that a
7402        // sibling test might have left behind so the
7403        // `permissions.mode` field reflects the documented zero-state.
7404        clear_permissions_mode_override_for_test();
7405        let caps = FeatureTier::Keyword.config().capabilities();
7406        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7407
7408        assert_eq!(val["schema_version"], "2");
7409
7410        // permissions zero-state: mode="advisory" (was "ask" in v1),
7411        // active_rules=0. `rule_summary` dropped from v2.
7412        assert_eq!(val["permissions"]["mode"], "advisory");
7413        assert_eq!(val["permissions"]["active_rules"], 0);
7414        assert!(
7415            val["permissions"].get("rule_summary").is_none(),
7416            "v2 honesty patch drops `permissions.rule_summary` (no per-rule serializer)"
7417        );
7418        // v0.6.3.1 (P4, audit G1): inheritance posture surfaced.
7419        assert_eq!(val["permissions"]["inheritance"], "enforced");
7420
7421        // hooks zero-state: 0 registered. `by_event` dropped from v2.
7422        assert_eq!(val["hooks"]["registered_count"], 0);
7423        assert!(
7424            val["hooks"].get("by_event").is_none(),
7425            "v2 honesty patch drops `hooks.by_event` (no event registry)"
7426        );
7427
7428        // hooks zero-state: 0 registered, by_event dropped (P1 honesty)
7429        assert_eq!(val["hooks"]["registered_count"], 0);
7430        assert!(
7431            val["hooks"].get("by_event").is_none(),
7432            "v2 drops hooks.by_event (no event registry)"
7433        );
7434        // P5 (G9): webhook_events must always surface the canonical
7435        // lifecycle events so integrators can pin a subscribe filter
7436        // against them.
7437        //
7438        // v0.7.0 K4 — `approval_requested` joined the list.
7439        // v0.7 J4 / G14 — `memory_link_invalidated` also joined.
7440        // Total: seven canonical event types.
7441        let events = val["hooks"]["webhook_events"].as_array().unwrap();
7442        assert_eq!(events.len(), 7);
7443        for expected in [
7444            "memory_store",
7445            "memory_promote",
7446            "memory_delete",
7447            "memory_link_created",
7448            "memory_link_invalidated",
7449            "memory_consolidated",
7450            "approval_requested",
7451        ] {
7452            assert!(
7453                events.iter().any(|v| v.as_str() == Some(expected)),
7454                "webhook_events missing {expected}"
7455            );
7456        }
7457
7458        // compaction zero-state: planned, not enabled, optional fields omitted
7459        assert_eq!(val["compaction"]["planned"], true);
7460        assert_eq!(val["compaction"]["enabled"], false);
7461        assert_eq!(val["compaction"]["version"], "v0.8+");
7462        assert!(
7463            val["compaction"].get("interval_minutes").is_none(),
7464            "Option::None values must be skipped in serialization"
7465        );
7466        assert!(val["compaction"].get("last_run_at").is_none());
7467        assert!(val["compaction"].get("last_run_stats").is_none());
7468
7469        // approval zero-state: 0 pending. `subscribers` and
7470        // `default_timeout_seconds` dropped from v2.
7471        assert_eq!(val["approval"]["pending_requests"], 0);
7472        assert!(
7473            val["approval"].get("subscribers").is_none(),
7474            "v2 honesty patch drops `approval.subscribers` (no subscription API)"
7475        );
7476        assert!(
7477            val["approval"].get("default_timeout_seconds").is_none(),
7478            "v2 honesty patch drops `approval.default_timeout_seconds` (no sweeper)"
7479        );
7480
7481        // v0.7.0 #1324 — substrate ships at v0.7.0; capability flag
7482        // reads `planned: false, enabled: false` at zero-state (no rows
7483        // in `memory_transcripts`, no operator-wired R5 hook yet). The
7484        // live MCP / HTTP overlay flips `enabled: true` when the
7485        // transcripts row count is non-zero.
7486        assert_eq!(val["transcripts"]["planned"], false);
7487        assert_eq!(val["transcripts"]["enabled"], false);
7488        assert_eq!(val["transcripts"]["version"], env!("CARGO_PKG_VERSION"));
7489
7490        // memory_reflection: planned-feature object (was bool).
7491        // v0.7.0 recursive-learning (issue #655) Tasks 1-6 shipped the
7492        // primitive, so the flag is `planned=false, enabled=true,
7493        // version="v0.7.0"`.
7494        assert_eq!(val["features"]["memory_reflection"]["planned"], false);
7495        assert_eq!(val["features"]["memory_reflection"]["enabled"], true);
7496        assert_eq!(val["features"]["memory_reflection"]["version"], "v0.7.0");
7497
7498        // Runtime-state defaults are conservative — they get overlaid
7499        // at the handler boundary based on the live embedder + reranker
7500        // handles. With no overlays, the keyword-tier daemon reports
7501        // `disabled` / `off`.
7502        assert_eq!(val["features"]["recall_mode_active"], "disabled");
7503        assert_eq!(val["features"]["reranker_active"], "off");
7504
7505        // v0.7 J1 — kg_backend zero-state: no SAL adapter wired yet,
7506        // so the field is None and elided from the JSON wire. Older
7507        // clients that don't know the field round-trip cleanly.
7508        assert!(
7509            val.get("kg_backend").is_none(),
7510            "kg_backend must be skipped from JSON when None (pre-J2 zero-state)"
7511        );
7512
7513        // Round-trip back to a typed Capabilities and confirm field
7514        // identity (proves Deserialize works for all reshaped structs).
7515        let restored: Capabilities = serde_json::from_value(val).unwrap();
7516        assert_eq!(restored.schema_version, "2");
7517        assert_eq!(restored.permissions.mode, "advisory");
7518        assert!(restored.compaction.status.planned);
7519        // v0.7.0 #1324 — transcripts substrate ships at v0.7.0; the
7520        // capability flag was `planned: true` pre-#1324 (mis-advertised
7521        // the substrate as roadmap-only). Round-trip now pins
7522        // `planned: false`.
7523        assert!(!restored.transcripts.status.planned);
7524        assert_eq!(restored.features.recall_mode_active, RecallMode::Disabled);
7525        assert_eq!(restored.features.reranker_active, RerankerMode::Off);
7526        assert!(restored.kg_backend.is_none());
7527    }
7528
7529    /// v0.7 J1 — when a SAL adapter populates `kg_backend`, the wire
7530    /// shape must serialise the literal snake-case tag and round-trip
7531    /// cleanly. Operators read this through `ai-memory doctor` and
7532    /// `memory_capabilities` to verify which traversal path their
7533    /// daemon actually runs.
7534    #[test]
7535    fn capabilities_kg_backend_serialises_when_set() {
7536        let mut caps = FeatureTier::Keyword.config().capabilities();
7537        caps.kg_backend = Some("age".to_string());
7538        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7539        assert_eq!(val["kg_backend"], "age");
7540
7541        caps.kg_backend = Some("cte".to_string());
7542        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7543        assert_eq!(val["kg_backend"], "cte");
7544
7545        // Round-trip the populated field for Deserialize coverage.
7546        let restored: Capabilities = serde_json::from_value(val).unwrap();
7547        assert_eq!(restored.kg_backend.as_deref(), Some("cte"));
7548    }
7549
7550    /// P1 honesty patch: legacy v1 projection preserves the old shape
7551    /// for clients that opt in via `Accept-Capabilities: v1`.
7552    #[test]
7553    fn capabilities_v1_projection_preserves_legacy_shape() {
7554        let caps = FeatureTier::Autonomous.config().capabilities();
7555        let v1 = caps.to_v1();
7556        let val: serde_json::Value = serde_json::to_value(&v1).unwrap();
7557
7558        // v1: no schema_version, no v2-only blocks
7559        assert!(
7560            val.get("schema_version").is_none(),
7561            "v1 has no schema_version"
7562        );
7563        assert!(
7564            val.get("permissions").is_none(),
7565            "v1 has no permissions block"
7566        );
7567        assert!(val.get("hooks").is_none());
7568        assert!(val.get("compaction").is_none());
7569        assert!(val.get("approval").is_none());
7570        assert!(val.get("transcripts").is_none());
7571
7572        // v1 keeps the four legacy top-level keys
7573        assert!(val["tier"].is_string());
7574        assert!(val["version"].is_string());
7575        assert!(val["features"].is_object());
7576        assert!(val["models"].is_object());
7577
7578        // v1 features.memory_reflection collapses to a bool. v0.7.0
7579        // recursive-learning (issue #655) Tasks 1-6 shipped the
7580        // primitive, so the v2 planned-feature object now has
7581        // `enabled = true` and the v1 bool projection is `true`.
7582        assert!(val["features"]["memory_reflection"].is_boolean());
7583        assert_eq!(val["features"]["memory_reflection"], true);
7584
7585        // v1 features carry no recall_mode_active / reranker_active
7586        assert!(val["features"].get("recall_mode_active").is_none());
7587        assert!(val["features"].get("reranker_active").is_none());
7588    }
7589
7590    #[test]
7591    fn config_default_is_empty() {
7592        let cfg = AppConfig::default();
7593        assert!(cfg.tier.is_none());
7594        assert!(cfg.db.is_none());
7595        assert!(cfg.ollama_url.is_none());
7596    }
7597
7598    #[test]
7599    fn config_parse_toml() {
7600        let toml_str = r#"
7601            tier = "smart"
7602            db = "/tmp/test.db"
7603            ollama_url = "http://localhost:11434"
7604            cross_encoder = true
7605        "#;
7606        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
7607        assert_eq!(cfg.tier.as_deref(), Some("smart"));
7608        assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
7609        assert!(cfg.cross_encoder.unwrap());
7610    }
7611
7612    #[test]
7613    fn resolved_ttl_defaults_match_hardcoded() {
7614        let resolved = ResolvedTtl::default();
7615        assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR));
7616        assert_eq!(resolved.mid_ttl_secs, Some(crate::SECS_PER_WEEK));
7617        assert_eq!(resolved.long_ttl_secs, None);
7618        assert_eq!(resolved.short_extend_secs, crate::SECS_PER_HOUR);
7619        assert_eq!(resolved.mid_extend_secs, crate::SECS_PER_DAY);
7620    }
7621
7622    #[test]
7623    fn resolved_ttl_from_partial_config() {
7624        let cfg = TtlConfig {
7625            mid_ttl_secs: Some(90 * crate::SECS_PER_DAY), // ~3 months
7626            ..Default::default()
7627        };
7628        let resolved = ResolvedTtl::from_config(Some(&cfg));
7629        assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR)); // unchanged
7630        assert_eq!(resolved.mid_ttl_secs, Some(90 * crate::SECS_PER_DAY)); // overridden
7631        assert_eq!(resolved.long_ttl_secs, None); // unchanged
7632    }
7633
7634    #[test]
7635    fn resolved_ttl_zero_means_no_expiry() {
7636        let cfg = TtlConfig {
7637            short_ttl_secs: Some(0),
7638            mid_ttl_secs: Some(0),
7639            ..Default::default()
7640        };
7641        let resolved = ResolvedTtl::from_config(Some(&cfg));
7642        assert_eq!(resolved.short_ttl_secs, None); // 0 → no expiry
7643        assert_eq!(resolved.mid_ttl_secs, None);
7644    }
7645
7646    #[test]
7647    fn resolved_ttl_clamps_overflow() {
7648        let cfg = TtlConfig {
7649            mid_ttl_secs: Some(i64::MAX),
7650            short_extend_secs: Some(-crate::SECS_PER_HOUR),
7651            ..Default::default()
7652        };
7653        let resolved = ResolvedTtl::from_config(Some(&cfg));
7654        // i64::MAX should be clamped to MAX_TTL_SECS (10 years)
7655        assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
7656        // negative extend should be clamped to 0
7657        assert_eq!(resolved.short_extend_secs, 0);
7658    }
7659
7660    #[test]
7661    fn ttl_config_parse_toml() {
7662        let toml_str = r#"
7663            tier = "semantic"
7664            archive_on_gc = false
7665            [ttl]
7666            mid_ttl_secs = 7776000
7667            short_extend_secs = 7200
7668        "#;
7669        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
7670        assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
7671        assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
7672        assert!(!cfg.effective_archive_on_gc());
7673    }
7674
7675    #[test]
7676    fn resolved_ttl_tier_methods() {
7677        let resolved = ResolvedTtl::default();
7678        assert_eq!(
7679            resolved.ttl_for_tier(&Tier::Short),
7680            Some(6 * crate::SECS_PER_HOUR)
7681        );
7682        assert_eq!(
7683            resolved.ttl_for_tier(&Tier::Mid),
7684            Some(crate::SECS_PER_WEEK)
7685        );
7686        assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
7687        assert_eq!(
7688            resolved.extend_for_tier(&Tier::Short),
7689            Some(crate::SECS_PER_HOUR)
7690        );
7691        assert_eq!(
7692            resolved.extend_for_tier(&Tier::Mid),
7693            Some(crate::SECS_PER_DAY)
7694        );
7695        assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
7696    }
7697
7698    #[test]
7699    fn config_effective_tier() {
7700        let cfg = AppConfig {
7701            tier: Some("smart".to_string()),
7702            ..Default::default()
7703        };
7704        // CLI override wins
7705        assert_eq!(
7706            cfg.effective_tier(Some("autonomous")),
7707            FeatureTier::Autonomous
7708        );
7709        // Config value used when no CLI
7710        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
7711    }
7712
7713    // --- v0.6.0.0 recall scoring (time-decay half-life) ---
7714
7715    #[test]
7716    fn scoring_defaults_match_spec() {
7717        let s = ResolvedScoring::default();
7718        assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
7719        assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
7720        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
7721        assert!(!s.legacy_scoring);
7722    }
7723
7724    #[test]
7725    fn scoring_from_config_overrides() {
7726        let cfg = RecallScoringConfig {
7727            half_life_days_short: Some(3.5),
7728            half_life_days_mid: Some(14.0),
7729            half_life_days_long: Some(730.0),
7730            legacy_scoring: false,
7731        };
7732        let s = ResolvedScoring::from_config(Some(&cfg));
7733        assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
7734        assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
7735        assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
7736    }
7737
7738    #[test]
7739    fn scoring_clamps_out_of_range() {
7740        let cfg = RecallScoringConfig {
7741            half_life_days_short: Some(-10.0),
7742            half_life_days_mid: Some(0.0),
7743            half_life_days_long: Some(1_000_000.0),
7744            legacy_scoring: false,
7745        };
7746        let s = ResolvedScoring::from_config(Some(&cfg));
7747        assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
7748        assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
7749        assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
7750    }
7751
7752    #[test]
7753    fn scoring_decay_at_half_life_is_half() {
7754        let s = ResolvedScoring::default();
7755        // Short tier half-life is 7 days → at age=7d, decay=0.5
7756        let d = s.decay_multiplier(&Tier::Short, 7.0);
7757        assert!((d - 0.5).abs() < 1e-9);
7758        let d = s.decay_multiplier(&Tier::Mid, 30.0);
7759        assert!((d - 0.5).abs() < 1e-9);
7760        let d = s.decay_multiplier(&Tier::Long, 365.0);
7761        assert!((d - 0.5).abs() < 1e-9);
7762    }
7763
7764    #[test]
7765    fn scoring_decay_monotonic() {
7766        let s = ResolvedScoring::default();
7767        let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
7768        let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
7769        // Older memories decay more (lower multiplier).
7770        assert!(d_new > d_old);
7771        assert!(d_new < 1.0);
7772        assert!(d_old > 0.0);
7773    }
7774
7775    #[test]
7776    fn scoring_decay_zero_age_is_one() {
7777        let s = ResolvedScoring::default();
7778        assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
7779        // Negative ages (clock skew, future timestamps) are also treated as fresh.
7780        assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
7781    }
7782
7783    #[test]
7784    fn scoring_legacy_disables_decay() {
7785        let cfg = RecallScoringConfig {
7786            legacy_scoring: true,
7787            ..Default::default()
7788        };
7789        let s = ResolvedScoring::from_config(Some(&cfg));
7790        // No decay regardless of age.
7791        assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
7792        assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
7793        assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
7794    }
7795
7796    #[test]
7797    fn effective_scoring_on_empty_config() {
7798        let cfg = AppConfig::default();
7799        let s = cfg.effective_scoring();
7800        assert_eq!(s.half_life_days_short, 7.0);
7801        assert!(!s.legacy_scoring);
7802    }
7803
7804    #[test]
7805    fn scoring_roundtrip_through_toml() {
7806        let toml_src = r"
7807[scoring]
7808half_life_days_short = 5.0
7809half_life_days_mid = 25.0
7810legacy_scoring = false
7811";
7812        let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
7813        let s = cfg.effective_scoring();
7814        assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
7815        assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
7816        // Unset long defaults.
7817        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
7818    }
7819
7820    // ---- Wave 3 (Closer T) tests for uncovered effective_* helpers
7821    // and write_default_if_missing. ----
7822
7823    #[test]
7824    fn effective_tier_cli_overrides_config() {
7825        let cfg = AppConfig {
7826            tier: Some("smart".to_string()),
7827            ..AppConfig::default()
7828        };
7829        // CLI flag wins over config.
7830        assert_eq!(
7831            cfg.effective_tier(Some("autonomous")),
7832            FeatureTier::Autonomous
7833        );
7834        // No CLI flag → config used.
7835        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
7836    }
7837
7838    #[test]
7839    fn effective_tier_unknown_falls_back_to_semantic() {
7840        let cfg = AppConfig::default();
7841        assert_eq!(
7842            cfg.effective_tier(Some("invalid-tier")),
7843            FeatureTier::Semantic
7844        );
7845        // No CLI, no config → default semantic.
7846        assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
7847    }
7848
7849    // ---- v0.6.4-001 — `effective_profile` resolution tests.
7850    //
7851    // Resolution order: CLI/env > [mcp].profile config > "core" default.
7852    // Clap merges CLI and env into the same `Option<&str>` before this
7853    // function sees it, so the function only needs to test "explicit
7854    // override > config > default". Env-var precedence over CLI cannot
7855    // happen by design (clap precedence is CLI > env), so it is not
7856    // tested at this layer.
7857
7858    #[test]
7859    fn effective_profile_cli_or_env_overrides_config() {
7860        let cfg = AppConfig {
7861            mcp: Some(McpConfig {
7862                profile: Some("graph".to_string()),
7863                allowlist: None,
7864                ..McpConfig::default()
7865            }),
7866            ..AppConfig::default()
7867        };
7868        // CLI/env value beats the config value.
7869        assert_eq!(
7870            cfg.effective_profile(Some("admin")).unwrap(),
7871            crate::profile::Profile::admin()
7872        );
7873        // No CLI/env → config used.
7874        assert_eq!(
7875            cfg.effective_profile(None).unwrap(),
7876            crate::profile::Profile::graph()
7877        );
7878    }
7879
7880    #[test]
7881    fn effective_profile_falls_back_to_core_default() {
7882        let cfg = AppConfig::default();
7883        // No mcp config, no CLI → core (the v0.6.4 default flip).
7884        assert_eq!(
7885            cfg.effective_profile(None).unwrap(),
7886            crate::profile::Profile::core()
7887        );
7888    }
7889
7890    #[test]
7891    fn effective_profile_surfaces_parse_error_for_unknown_family() {
7892        let cfg = AppConfig::default();
7893        assert!(matches!(
7894            cfg.effective_profile(Some("xyz")),
7895            Err(crate::profile::ProfileParseError::UnknownFamily(_))
7896        ));
7897    }
7898
7899    #[test]
7900    fn effective_profile_surfaces_parse_error_for_mixed_case() {
7901        let cfg = AppConfig::default();
7902        assert!(matches!(
7903            cfg.effective_profile(Some("Core")),
7904            Err(crate::profile::ProfileParseError::CaseMismatch(_))
7905        ));
7906    }
7907
7908    // ---- v0.6.4-008 — `[mcp.allowlist]` resolution tests.
7909
7910    fn allowlist_table(rows: &[(&str, &[&str])]) -> McpConfig {
7911        let mut map = std::collections::HashMap::new();
7912        for (k, v) in rows {
7913            map.insert(
7914                (*k).to_string(),
7915                v.iter().map(|s| (*s).to_string()).collect(),
7916            );
7917        }
7918        McpConfig {
7919            profile: None,
7920            allowlist: Some(map),
7921            ..McpConfig::default()
7922        }
7923    }
7924
7925    #[test]
7926    fn allowlist_disabled_when_table_absent() {
7927        let cfg = McpConfig::default();
7928        assert_eq!(
7929            cfg.allowlist_decision(Some("alice"), "graph"),
7930            AllowlistDecision::Disabled
7931        );
7932    }
7933
7934    #[test]
7935    fn allowlist_disabled_when_table_empty() {
7936        let cfg = McpConfig {
7937            profile: None,
7938            allowlist: Some(std::collections::HashMap::new()),
7939            ..McpConfig::default()
7940        };
7941        assert_eq!(
7942            cfg.allowlist_decision(Some("alice"), "graph"),
7943            AllowlistDecision::Disabled
7944        );
7945    }
7946
7947    #[test]
7948    fn allowlist_exact_match_grants_or_denies_per_family_set() {
7949        let cfg = allowlist_table(&[("alice", &["core", "graph"]), ("*", &["core"])]);
7950        assert_eq!(
7951            cfg.allowlist_decision(Some("alice"), "graph"),
7952            AllowlistDecision::Allow
7953        );
7954        assert_eq!(
7955            cfg.allowlist_decision(Some("alice"), "power"),
7956            AllowlistDecision::Deny
7957        );
7958    }
7959
7960    #[test]
7961    fn allowlist_full_grants_every_family() {
7962        let cfg = allowlist_table(&[("bob", &["full"])]);
7963        assert_eq!(
7964            cfg.allowlist_decision(Some("bob"), "graph"),
7965            AllowlistDecision::Allow
7966        );
7967        assert_eq!(
7968            cfg.allowlist_decision(Some("bob"), "archive"),
7969            AllowlistDecision::Allow
7970        );
7971    }
7972
7973    #[test]
7974    fn allowlist_wildcard_default_for_unknown_agents() {
7975        let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
7976        assert_eq!(
7977            cfg.allowlist_decision(Some("eve"), "core"),
7978            AllowlistDecision::Allow
7979        );
7980        assert_eq!(
7981            cfg.allowlist_decision(Some("eve"), "graph"),
7982            AllowlistDecision::Deny
7983        );
7984    }
7985
7986    #[test]
7987    fn allowlist_default_deny_when_no_wildcard() {
7988        let cfg = allowlist_table(&[("alice", &["full"])]);
7989        assert_eq!(
7990            cfg.allowlist_decision(Some("eve"), "core"),
7991            AllowlistDecision::Deny
7992        );
7993    }
7994
7995    #[test]
7996    fn allowlist_longest_prefix_match_wins() {
7997        let cfg = allowlist_table(&[
7998            ("ai:", &["core"]),
7999            ("ai:claude-code", &["full"]),
8000            ("*", &["core"]),
8001        ]);
8002        // The longer prefix takes precedence over the shorter one.
8003        assert_eq!(
8004            cfg.allowlist_decision(Some("ai:claude-code@host"), "graph"),
8005            AllowlistDecision::Allow
8006        );
8007        // Shorter prefix still works for other ai:* agents.
8008        assert_eq!(
8009            cfg.allowlist_decision(Some("ai:codex@host"), "graph"),
8010            AllowlistDecision::Deny
8011        );
8012    }
8013
8014    #[test]
8015    fn allowlist_no_agent_id_uses_wildcard() {
8016        // Tier-1 / anonymous: no agent_id provided → only the wildcard
8017        // rule is consulted.
8018        let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
8019        assert_eq!(
8020            cfg.allowlist_decision(None, "core"),
8021            AllowlistDecision::Allow
8022        );
8023        assert_eq!(
8024            cfg.allowlist_decision(None, "graph"),
8025            AllowlistDecision::Deny
8026        );
8027    }
8028
8029    #[test]
8030    fn effective_db_cli_path_wins_when_non_default() {
8031        let cfg = AppConfig {
8032            db: Some("/from/config.db".to_string()),
8033            ..AppConfig::default()
8034        };
8035        let cli_path = Path::new("/from/cli.db");
8036        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
8037    }
8038
8039    #[test]
8040    fn effective_db_falls_back_to_config_when_cli_default() {
8041        let cfg = AppConfig {
8042            db: Some("/from/config.db".to_string()),
8043            ..AppConfig::default()
8044        };
8045        // The CLI default is "ai-memory.db" — config wins for that case.
8046        assert_eq!(
8047            cfg.effective_db(Path::new("ai-memory.db")),
8048            PathBuf::from("/from/config.db")
8049        );
8050    }
8051
8052    #[test]
8053    fn effective_db_falls_back_to_cli_when_no_config() {
8054        let cfg = AppConfig::default();
8055        let cli_path = Path::new("ai-memory.db");
8056        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
8057    }
8058
8059    #[test]
8060    fn effective_db_expands_tilde_against_home() {
8061        // #507: `db = "~/.claude/ai-memory.db"` must resolve to $HOME-based
8062        // path rather than the literal four-char prefix. Use env_var_lock
8063        // because HOME mutation is process-global.
8064        let _g = env_var_lock();
8065        let prev_home = std::env::var("HOME").ok();
8066        // SAFETY: serialized via env_var_lock; restored below.
8067        unsafe { std::env::set_var("HOME", "/expanded/home") };
8068        let cfg = AppConfig {
8069            db: Some("~/.claude/ai-memory.db".to_string()),
8070            ..AppConfig::default()
8071        };
8072        assert_eq!(
8073            cfg.effective_db(Path::new("ai-memory.db")),
8074            PathBuf::from("/expanded/home/.claude/ai-memory.db")
8075        );
8076        // Bare `~` resolves to $HOME itself.
8077        let cfg_bare = AppConfig {
8078            db: Some("~".to_string()),
8079            ..AppConfig::default()
8080        };
8081        assert_eq!(
8082            cfg_bare.effective_db(Path::new("ai-memory.db")),
8083            PathBuf::from("/expanded/home")
8084        );
8085        // Restore.
8086        match prev_home {
8087            Some(h) => unsafe { std::env::set_var("HOME", h) },
8088            None => unsafe { std::env::remove_var("HOME") },
8089        }
8090    }
8091
8092    #[test]
8093    fn effective_ollama_url_default_when_unset() {
8094        let cfg = AppConfig::default();
8095        assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
8096    }
8097
8098    #[test]
8099    fn effective_ollama_url_uses_configured_value() {
8100        let cfg = AppConfig {
8101            ollama_url: Some("http://my-host:9999".to_string()),
8102            ..AppConfig::default()
8103        };
8104        assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
8105    }
8106
8107    #[test]
8108    fn effective_embed_url_falls_back_to_ollama_url() {
8109        let cfg = AppConfig {
8110            ollama_url: Some("http://ollama:11434".to_string()),
8111            ..AppConfig::default()
8112        };
8113        // No embed_url → fall back to ollama_url.
8114        assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
8115    }
8116
8117    #[test]
8118    fn effective_embed_url_uses_dedicated_value_when_set() {
8119        let cfg = AppConfig {
8120            ollama_url: Some("http://ollama:11434".to_string()),
8121            embed_url: Some("http://embed:8080".to_string()),
8122            ..AppConfig::default()
8123        };
8124        // Dedicated embed_url wins.
8125        assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
8126    }
8127
8128    #[test]
8129    fn effective_embed_url_uses_default_when_neither_set() {
8130        let cfg = AppConfig::default();
8131        assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
8132    }
8133
8134    #[test]
8135    fn effective_archive_on_gc_default_is_true() {
8136        let cfg = AppConfig::default();
8137        assert!(cfg.effective_archive_on_gc());
8138    }
8139
8140    #[test]
8141    fn effective_archive_on_gc_respects_explicit_false() {
8142        let cfg = AppConfig {
8143            archive_on_gc: Some(false),
8144            ..AppConfig::default()
8145        };
8146        assert!(!cfg.effective_archive_on_gc());
8147    }
8148
8149    #[test]
8150    fn effective_autonomous_hooks_default_is_false() {
8151        // M9 — process-wide serialization via env_var_lock.
8152        let _g = env_var_lock();
8153        // SAFETY: env mutation serialised by `_g`.
8154        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
8155        let cfg = AppConfig::default();
8156        assert!(!cfg.effective_autonomous_hooks());
8157    }
8158
8159    #[test]
8160    fn effective_autonomous_hooks_config_value_used_when_env_unset() {
8161        // M9 — process-wide serialization via env_var_lock.
8162        let _g = env_var_lock();
8163        // SAFETY: env mutation serialised by `_g`.
8164        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
8165        let cfg = AppConfig {
8166            autonomous_hooks: Some(true),
8167            ..AppConfig::default()
8168        };
8169        assert!(cfg.effective_autonomous_hooks());
8170    }
8171
8172    #[test]
8173    fn effective_anonymize_default_falls_back_to_config() {
8174        // M9 — process-wide serialization via env_var_lock.
8175        let _g = env_var_lock();
8176        // SAFETY: env mutation serialised by `_g`.
8177        unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
8178        let cfg = AppConfig::default();
8179        assert!(!cfg.effective_anonymize_default());
8180    }
8181
8182    #[test]
8183    fn write_default_if_missing_creates_file_then_noops() {
8184        // M9 — process-wide serialization via env_var_lock.
8185        let _g = env_var_lock();
8186        // Use a temp dir as $HOME so we don't clobber a real config.
8187        let tmp = tempfile::tempdir().unwrap();
8188        // SAFETY: env mutation serialised by `_g`.
8189        unsafe { std::env::set_var("HOME", tmp.path()) };
8190        // First call writes the file.
8191        AppConfig::write_default_if_missing();
8192        let expected = AppConfig::config_path().unwrap();
8193        assert!(expected.exists(), "config not written at {expected:?}");
8194        let original = std::fs::read_to_string(&expected).unwrap();
8195        assert!(original.contains("ai-memory configuration"));
8196        // Second call must NOT overwrite (idempotent).
8197        std::fs::write(&expected, "# user-edited\n").unwrap();
8198        AppConfig::write_default_if_missing();
8199        let after = std::fs::read_to_string(&expected).unwrap();
8200        assert_eq!(after, "# user-edited\n");
8201    }
8202
8203    #[test]
8204    fn config_path_returns_some_when_home_set() {
8205        // M9 — process-wide serialization via env_var_lock.
8206        let _g = env_var_lock();
8207        // SAFETY: env mutation serialised by `_g`.
8208        unsafe { std::env::set_var("HOME", "/some/home") };
8209        let path = AppConfig::config_path().unwrap();
8210        assert!(path.starts_with("/some/home"));
8211    }
8212
8213    #[test]
8214    fn load_from_returns_default_for_missing_file() {
8215        // Non-existent path → default config.
8216        let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
8217        assert!(cfg.tier.is_none());
8218        assert!(cfg.db.is_none());
8219    }
8220
8221    #[test]
8222    fn load_from_returns_default_for_unparseable_toml() {
8223        // Garbage TOML → load_from prints a warning and returns default.
8224        let tmp = tempfile::NamedTempFile::new().unwrap();
8225        std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
8226        let cfg = AppConfig::load_from(tmp.path());
8227        assert!(cfg.tier.is_none());
8228    }
8229
8230    #[test]
8231    fn load_from_parses_valid_toml() {
8232        let tmp = tempfile::NamedTempFile::new().unwrap();
8233        std::fs::write(
8234            tmp.path(),
8235            r#"
8236                tier = "smart"
8237                db = "/disk.db"
8238            "#,
8239        )
8240        .unwrap();
8241        let cfg = AppConfig::load_from(tmp.path());
8242        assert_eq!(cfg.tier.as_deref(), Some("smart"));
8243        assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
8244    }
8245
8246    // -----------------------------------------------------------------
8247    // v0.7 I5 — auto_extract opt-in resolver
8248    // -----------------------------------------------------------------
8249
8250    #[test]
8251    fn auto_extract_default_off_when_no_namespaces_block() {
8252        let cfg = TranscriptsConfig::default();
8253        assert!(!cfg.auto_extract_for("agent/claude"));
8254        assert!(!cfg.auto_extract_for("anything"));
8255    }
8256
8257    #[test]
8258    fn auto_extract_exact_namespace_match_wins() {
8259        let mut nss = std::collections::HashMap::new();
8260        nss.insert(
8261            "agent/claude".into(),
8262            TranscriptNamespaceConfig {
8263                auto_extract: Some(true),
8264                ..Default::default()
8265            },
8266        );
8267        // Wildcard says "off" — exact match must still flip it on.
8268        nss.insert(
8269            "*".into(),
8270            TranscriptNamespaceConfig {
8271                auto_extract: Some(false),
8272                ..Default::default()
8273            },
8274        );
8275        let cfg = TranscriptsConfig {
8276            namespaces: Some(nss),
8277            ..Default::default()
8278        };
8279        assert!(cfg.auto_extract_for("agent/claude"));
8280        assert!(!cfg.auto_extract_for("agent/gpt"));
8281    }
8282
8283    #[test]
8284    fn auto_extract_prefix_match_then_wildcard_fallback() {
8285        let mut nss = std::collections::HashMap::new();
8286        nss.insert(
8287            "team/security/*".into(),
8288            TranscriptNamespaceConfig {
8289                auto_extract: Some(true),
8290                ..Default::default()
8291            },
8292        );
8293        nss.insert(
8294            "*".into(),
8295            TranscriptNamespaceConfig {
8296                auto_extract: Some(false),
8297                ..Default::default()
8298            },
8299        );
8300        let cfg = TranscriptsConfig {
8301            namespaces: Some(nss),
8302            ..Default::default()
8303        };
8304        assert!(cfg.auto_extract_for("team/security/audit"));
8305        assert!(!cfg.auto_extract_for("team/eng/main"));
8306    }
8307
8308    #[test]
8309    fn auto_extract_unset_field_inherits_default_off() {
8310        // A namespace block that sets only TTL — auto_extract is None
8311        // and so falls through to the next layer (wildcard, then off).
8312        let mut nss = std::collections::HashMap::new();
8313        nss.insert(
8314            "agent/claude".into(),
8315            TranscriptNamespaceConfig {
8316                default_ttl_secs: Some(crate::SECS_PER_HOUR),
8317                auto_extract: None,
8318                ..Default::default()
8319            },
8320        );
8321        let cfg = TranscriptsConfig {
8322            namespaces: Some(nss),
8323            ..Default::default()
8324        };
8325        assert!(!cfg.auto_extract_for("agent/claude"));
8326    }
8327
8328    // -----------------------------------------------------------------
8329    // L1 fix (v0.7.0): unknown top-level keys WARN diagnostic
8330    // -----------------------------------------------------------------
8331    //
8332    // The earlier Plan C bug planted `[memory]`, `[autonomous]`,
8333    // `[governance]`, `[federation]` tables in the operator's
8334    // config.toml — none of them are real `AppConfig` fields, so serde
8335    // silently dropped them and the operator's intent never reached the
8336    // daemon. The fix warns on every unknown top-level key while still
8337    // loading the config gracefully.
8338
8339    /// Top-level key not in `AppConfig` is reported via `tracing::warn!`
8340    /// AND the config still loads with recognised fields intact.
8341    #[test]
8342    fn load_from_warns_on_unknown_top_level_key_but_still_loads() {
8343        // Construct a config that mixes a real key (`tier`) with the
8344        // unknown `[memory]` table from the Plan C bug. The recognised
8345        // `tier = "autonomous"` at the top level must survive (i.e. the
8346        // unknown `[memory] tier = "ignored"` does NOT shadow it —
8347        // top-level wins because `[memory]` is a different namespace
8348        // entirely from `AppConfig.tier`).
8349        let toml_src = "tier = \"autonomous\"\n\n[memory]\ntier = \"ignored\"\n";
8350
8351        let tmp = tempfile::NamedTempFile::new().expect("create temp file");
8352        std::fs::write(tmp.path(), toml_src).expect("write temp config");
8353
8354        // We do NOT install a tracing subscriber here — `tracing-test`
8355        // is not a dev-dep, and the spec explicitly allows skipping the
8356        // "warn-was-emitted" assertion when capturing is awkward. The
8357        // important contract is:
8358        //   (a) load_from returns a populated AppConfig (no panic),
8359        //   (b) the recognised top-level `tier` survives,
8360        //   (c) the unknown `[memory]` table did NOT block the load.
8361        // The warn itself is exercised at runtime — verify it fires by
8362        // running `RUST_LOG=warn AI_MEMORY_NO_CONFIG=0 ai-memory ...`
8363        // against a config with a stray section.
8364        let cfg = AppConfig::load_from(tmp.path());
8365
8366        assert_eq!(
8367            cfg.tier.as_deref(),
8368            Some("autonomous"),
8369            "top-level `tier` must survive even when an unknown `[memory]` table is present",
8370        );
8371    }
8372
8373    /// Every field in `AppConfig` is enumerated in the expected-key
8374    /// set, so renaming a struct field will not silently start
8375    /// emitting bogus warnings for the new name.
8376    ///
8377    /// Regression guard: if you add a new top-level field to
8378    /// `AppConfig`, you MUST also add it to the `EXPECTED_KEYS` const
8379    /// inside `AppConfig::warn_unknown_top_level_keys`. This test
8380    /// enforces parity by serialising a fully-populated `AppConfig` to
8381    /// TOML and asserting that every emitted top-level key is in the
8382    /// expected set.
8383    #[test]
8384    fn warn_unknown_top_level_keys_covers_every_appconfig_field() {
8385        // Build an AppConfig with every Option populated so serde emits
8386        // every field. We only need the keys, not the values, so
8387        // default placeholder sub-structs are fine.
8388        let cfg = AppConfig {
8389            tier: Some("keyword".into()),
8390            db: Some(String::new()),
8391            ollama_url: Some(String::new()),
8392            embed_url: Some(String::new()),
8393            embedding_model: Some(String::new()),
8394            llm_model: Some(String::new()),
8395            auto_tag_model: Some(String::new()),
8396            cross_encoder: Some(false),
8397            default_namespace: Some(String::new()),
8398            max_memory_mb: Some(0),
8399            ttl: Some(TtlConfig::default()),
8400            archive_on_gc: Some(false),
8401            api_key: Some(String::new()),
8402            archive_max_days: Some(0),
8403            identity: Some(IdentityConfig::default()),
8404            scoring: Some(RecallScoringConfig::default()),
8405            autonomous_hooks: Some(false),
8406            logging: Some(LoggingConfig::default()),
8407            audit: Some(AuditConfig::default()),
8408            boot: Some(BootConfig::default()),
8409            mcp: Some(McpConfig::default()),
8410            permissions: Some(PermissionsConfig::default()),
8411            transcripts: Some(TranscriptsConfig::default()),
8412            hooks: Some(HooksConfig::default()),
8413            subscriptions: Some(SubscriptionsConfig::default()),
8414            postgres_statement_timeout_secs: Some(30),
8415            postgres_pool_max_connections: Some(16),
8416            postgres_pool_min_connections: Some(2),
8417            postgres_acquire_timeout_secs: Some(30),
8418            request_timeout_secs: Some(60),
8419            llm_call_timeout_secs: Some(30),
8420            verify: Some(VerifyConfig::default()),
8421            mcp_federation_forward_url: Some(String::new()),
8422            agents: Some(AgentsConfig::default()),
8423            governance: Some(GovernanceConfig::default()),
8424            confidence: Some(ConfidenceConfig::default()),
8425            admin: Some(AdminConfig::default()),
8426            // v0.7.x (#1146) — enterprise configuration sections.
8427            schema_version: Some(2),
8428            llm: Some(LlmSection::default()),
8429            embeddings: Some(EmbeddingsSection::default()),
8430            reranker: Some(RerankerSection::default()),
8431            curator: Some(CuratorSection::default()),
8432            storage: Some(StorageSection::default()),
8433            limits: Some(LimitsSection::default()),
8434        };
8435
8436        let serialised = toml::to_string(&cfg).expect("serialise AppConfig to TOML");
8437        let value: toml::Value =
8438            toml::from_str(&serialised).expect("re-parse serialised AppConfig");
8439        let table = value.as_table().expect("serialised AppConfig is a table");
8440
8441        // Mirror the const in `warn_unknown_top_level_keys`. Keep in
8442        // sync — if this assertion fires, you forgot to update the
8443        // expected-keys list when adding a new AppConfig field.
8444        const EXPECTED_KEYS: &[&str] = &[
8445            "tier",
8446            "db",
8447            "ollama_url",
8448            "embed_url",
8449            "embedding_model",
8450            "llm_model",
8451            "auto_tag_model",
8452            "cross_encoder",
8453            "default_namespace",
8454            "max_memory_mb",
8455            "ttl",
8456            "archive_on_gc",
8457            "api_key",
8458            "archive_max_days",
8459            "identity",
8460            "scoring",
8461            "autonomous_hooks",
8462            "logging",
8463            "audit",
8464            "boot",
8465            "mcp",
8466            "permissions",
8467            "transcripts",
8468            "hooks",
8469            "subscriptions",
8470            "postgres_statement_timeout_secs",
8471            "postgres_pool_max_connections",
8472            "postgres_pool_min_connections",
8473            "postgres_acquire_timeout_secs",
8474            "request_timeout_secs",
8475            "llm_call_timeout_secs",
8476            "verify",
8477            "mcp_federation_forward_url",
8478            "agents",
8479            "governance",
8480            "confidence",
8481            "admin",
8482            // v0.7.x (#1146) — enterprise configuration sections.
8483            "schema_version",
8484            "llm",
8485            "embeddings",
8486            "reranker",
8487            "curator",
8488            "storage",
8489            "limits",
8490        ];
8491
8492        for key in table.keys() {
8493            assert!(
8494                EXPECTED_KEYS.contains(&key.as_str()),
8495                "AppConfig field `{key}` is not in EXPECTED_KEYS — \
8496                 update `warn_unknown_top_level_keys` to keep parity",
8497            );
8498        }
8499    }
8500
8501    /// v0.7.0 L15 — assert that:
8502    ///  1. `AppConfig::default()` leaves `auto_tag_model` as `None` so a
8503    ///     daemon with no operator override sees the absent state (which
8504    ///     `maybe_auto_tag` interprets as "use the client's configured
8505    ///     `llm_model`"); and
8506    ///  2. the documented default config.toml template spot-checks
8507    ///     `gemma3:4b` as the recommended value — closes the L14
8508    ///     NHI-D-autotag-empty finding where Gemma 4 thinking-mode
8509    ///     latency hit the 30s autonomy timeout.
8510    #[test]
8511    fn auto_tag_model_default_falls_back_to_none_and_template_documents_default_gemma3_4b() {
8512        // (1) compile-time default leaves auto_tag_model = None.
8513        let cfg = AppConfig::default();
8514        assert!(
8515            cfg.auto_tag_model.is_none(),
8516            "fresh AppConfig must leave auto_tag_model = None so callers \
8517             fall back to llm_model"
8518        );
8519
8520        // (2) the default config.toml template the daemon writes to disk
8521        // must document the recommended gemma3:4b value and mention
8522        // auto_tag_model — operators rely on the inline template as the
8523        // authoritative knob reference.
8524        //
8525        // We can't reach the private `default_toml` constant directly,
8526        // so write it to a tempdir via `write_default_if_missing` and
8527        // read it back. Mirrors the pattern used by
8528        // `default_config_includes_*` tests above.
8529        //
8530        // M9 — HOME mutation is process-global; other tests in this
8531        // module also flip HOME. Serialise via env_var_lock so parallel
8532        // `cargo test --jobs N` runs cannot interleave reads of HOME
8533        // mid-mutation.
8534        let _g = env_var_lock();
8535        let tmp = tempfile::tempdir().expect("tempdir");
8536        // SAFETY: env mutation serialised by `_g`.
8537        unsafe { std::env::set_var("HOME", tmp.path()) };
8538        AppConfig::write_default_if_missing();
8539        let written = AppConfig::config_path().expect("config_path resolves");
8540        let contents = std::fs::read_to_string(&written).expect("default toml written");
8541        assert!(
8542            contents.contains("auto_tag_model"),
8543            "default config.toml must document the auto_tag_model knob; \
8544             got:\n{contents}"
8545        );
8546        assert!(
8547            contents.contains("gemma3:4b"),
8548            "default config.toml must mention gemma3:4b as the L15 \
8549             recommended default; got:\n{contents}"
8550        );
8551    }
8552
8553    // ---- C-5 (#699): close lib-tier gaps in config.rs (currently 90.76%).
8554    // Targets serde default functions, env-var override branches, and
8555    // display impls that no other test exercises. ----
8556
8557    #[test]
8558    fn tier_llm_model_is_agnostic_gate() {
8559        // The Gemma-only `LlmModel` enum was removed (#1490): no model name
8560        // survives as a config-surface identifier. The LLM-capable tiers
8561        // carry the provider-agnostic compiled default; keyword/semantic
8562        // carry `None` (LLM disabled). Pin the gate + the single-source-of-
8563        // truth default rather than any hardcoded vendor string.
8564        assert!(FeatureTier::Keyword.config().llm_model.is_none());
8565        assert!(FeatureTier::Semantic.config().llm_model.is_none());
8566        assert_eq!(
8567            FeatureTier::Smart.config().llm_model.as_deref(),
8568            Some(default_tier_llm_model())
8569        );
8570        assert_eq!(
8571            FeatureTier::Autonomous.config().llm_model.as_deref(),
8572            Some(default_tier_llm_model())
8573        );
8574        // The default routes through the agnostic resolver table, never a
8575        // model-named identifier.
8576        assert_eq!(
8577            default_tier_llm_model(),
8578            backend_default_model(crate::llm::BACKEND_OLLAMA)
8579        );
8580    }
8581
8582    #[test]
8583    fn feature_tier_display_matches_as_str() {
8584        // Lines 183-185: `FeatureTier::Display::fmt` writes `as_str`.
8585        assert_eq!(format!("{}", FeatureTier::Keyword), "keyword");
8586        assert_eq!(format!("{}", FeatureTier::Semantic), "semantic");
8587        assert_eq!(format!("{}", FeatureTier::Smart), "smart");
8588        assert_eq!(format!("{}", FeatureTier::Autonomous), "autonomous");
8589    }
8590
8591    #[test]
8592    fn default_recall_mode_is_disabled() {
8593        // Lines 630-632: serde default helper.
8594        assert_eq!(default_recall_mode(), RecallMode::Disabled);
8595    }
8596
8597    #[test]
8598    fn default_reranker_mode_is_off() {
8599        // Lines 634-636: serde default helper.
8600        assert_eq!(default_reranker_mode(), RerankerMode::Off);
8601    }
8602
8603    #[test]
8604    fn default_hook_events_count_matches_constant() {
8605        // Lines 731-733: serde default helper.
8606        assert_eq!(default_hook_events_count(), HOOK_EVENTS_COUNT);
8607    }
8608
8609    #[test]
8610    fn default_reflection_boost_returns_default_report() {
8611        // Lines 621-623: serde default helper. Calls the `Default::default`
8612        // impl on `ReflectionBoostReport`.
8613        let r = default_reflection_boost();
8614        let d = ReflectionBoostReport::default();
8615        // Lazy compare via Debug — the struct has no PartialEq.
8616        assert_eq!(format!("{r:?}"), format!("{d:?}"));
8617    }
8618
8619    #[test]
8620    fn permissions_mode_default_is_advisory() {
8621        // Lines 2403-2405: `impl Default for PermissionsMode`.
8622        let m: PermissionsMode = Default::default();
8623        assert_eq!(m, PermissionsMode::Advisory);
8624    }
8625
8626    #[test]
8627    fn active_permissions_mode_uses_named_fallback_when_unset_then_honors_setter() {
8628        // v0.7.0 H2 de-silencing: when boot has NOT installed a mode,
8629        // the gate reader returns the explicit
8630        // UNINITIALIZED_PERMISSIONS_MODE_FALLBACK constant (and emits a
8631        // one-shot WARN). Once a mode is installed, the reader honors it.
8632        let _serialise = lock_permissions_mode_for_test();
8633        clear_permissions_mode_override_for_test();
8634        assert_eq!(
8635            active_permissions_mode(),
8636            UNINITIALIZED_PERMISSIONS_MODE_FALLBACK,
8637            "unset gate must return the named pre-init fallback"
8638        );
8639        set_active_permissions_mode(PermissionsMode::Enforce);
8640        assert_eq!(
8641            active_permissions_mode(),
8642            PermissionsMode::Enforce,
8643            "installed mode must win over the fallback"
8644        );
8645        // Restore the unset state for subsequent tests.
8646        clear_permissions_mode_override_for_test();
8647    }
8648
8649    #[test]
8650    fn set_allow_loopback_webhooks_round_trips() {
8651        // Lines 2357-2359: pub setter — just observe it does not panic
8652        // and that effective_allow_loopback_webhooks can read the value.
8653        // (The atomic is process-global; restore the prior value at end.)
8654        let prior = ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst);
8655        set_allow_loopback_webhooks(true);
8656        assert!(ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
8657        set_allow_loopback_webhooks(false);
8658        assert!(!ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
8659        // Restore.
8660        ALLOW_LOOPBACK_WEBHOOKS.store(prior, std::sync::atomic::Ordering::SeqCst);
8661    }
8662
8663    #[test]
8664    fn reset_permissions_decision_counts_zeros_all_atomics() {
8665        // Lines 2619-2623: test-only reset helper. Increment then reset.
8666        // Post-#1174 PR7: counters live behind the `DECISION_COUNTERS`
8667        // struct; we exercise them via the public surface to keep the
8668        // test resilient to internal reshape.
8669        let _serialise = lock_permissions_mode_for_test();
8670        reset_permissions_decision_counts_for_test();
8671        record_permissions_decision(PermissionsMode::Enforce);
8672        record_permissions_decision(PermissionsMode::Enforce);
8673        record_permissions_decision(PermissionsMode::Enforce);
8674        record_permissions_decision(PermissionsMode::Enforce);
8675        record_permissions_decision(PermissionsMode::Enforce);
8676        record_permissions_decision(PermissionsMode::Advisory);
8677        record_permissions_decision(PermissionsMode::Advisory);
8678        record_permissions_decision(PermissionsMode::Advisory);
8679        record_permissions_decision(PermissionsMode::Off);
8680        let pre = permissions_decision_counts();
8681        assert_eq!(pre.enforce, 5);
8682        assert_eq!(pre.advisory, 3);
8683        assert_eq!(pre.off, 1);
8684        reset_permissions_decision_counts_for_test();
8685        let post = permissions_decision_counts();
8686        assert_eq!(post.enforce, 0);
8687        assert_eq!(post.advisory, 0);
8688        assert_eq!(post.off, 0);
8689    }
8690
8691    #[test]
8692    fn effective_allow_loopback_webhooks_env_var_true_returns_true() {
8693        // Lines 2281-2297: env-var override branch (truthy).
8694        let _g = env_var_lock();
8695        let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8696        unsafe {
8697            std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "yes");
8698        }
8699        let cfg = AppConfig::default();
8700        assert!(cfg.effective_allow_loopback_webhooks());
8701        unsafe {
8702            match prior {
8703                Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8704                None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8705            }
8706        }
8707    }
8708
8709    #[test]
8710    fn effective_allow_loopback_webhooks_env_var_false_returns_false() {
8711        // Lines 2281-2297: env-var override (falsy).
8712        let _g = env_var_lock();
8713        let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8714        unsafe {
8715            std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "no");
8716        }
8717        let cfg = AppConfig::default();
8718        assert!(!cfg.effective_allow_loopback_webhooks());
8719        unsafe {
8720            match prior {
8721                Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8722                None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8723            }
8724        }
8725    }
8726
8727    #[test]
8728    fn effective_allow_loopback_webhooks_env_var_invalid_falls_back_to_config() {
8729        // Lines 2286-2292: invalid env value falls back to config.toml.
8730        let _g = env_var_lock();
8731        let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8732        unsafe {
8733            std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "kinda");
8734        }
8735        let cfg = AppConfig::default();
8736        // With no [subscriptions] table the default is false.
8737        assert!(!cfg.effective_allow_loopback_webhooks());
8738        unsafe {
8739            match prior {
8740                Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8741                None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8742            }
8743        }
8744    }
8745
8746    #[test]
8747    fn effective_permissions_mode_env_var_enforce_wins() {
8748        // Lines 3144-3169: env override path → Enforce.
8749        let _g = env_var_lock();
8750        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8751        unsafe {
8752            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "enforce");
8753        }
8754        let cfg = AppConfig::default();
8755        assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Enforce);
8756        unsafe {
8757            match prior {
8758                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8759                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8760            }
8761        }
8762    }
8763
8764    #[test]
8765    fn effective_permissions_mode_env_var_advisory_wins() {
8766        // Lines 3148: env override path → Advisory.
8767        let _g = env_var_lock();
8768        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8769        unsafe {
8770            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "ADVISORY");
8771        }
8772        let cfg = AppConfig::default();
8773        assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Advisory);
8774        unsafe {
8775            match prior {
8776                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8777                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8778            }
8779        }
8780    }
8781
8782    #[test]
8783    fn effective_permissions_mode_env_var_off_wins() {
8784        // Lines 3149: env override path → Off.
8785        let _g = env_var_lock();
8786        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8787        unsafe {
8788            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "off");
8789        }
8790        let cfg = AppConfig::default();
8791        assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Off);
8792        unsafe {
8793            match prior {
8794                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8795                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8796            }
8797        }
8798    }
8799
8800    #[test]
8801    fn effective_permissions_mode_env_var_invalid_falls_back_to_config() {
8802        // Lines 3150-3156: invalid env → falls through to resolve_v07_default_mode.
8803        let _g = env_var_lock();
8804        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8805        unsafe {
8806            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "weird");
8807        }
8808        let cfg = AppConfig::default();
8809        // The resolver returns a value (we don't pin which — just that it returns).
8810        let _ = cfg.effective_permissions_mode();
8811        unsafe {
8812            match prior {
8813                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8814                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8815            }
8816        }
8817    }
8818
8819    #[test]
8820    fn effective_permission_rules_returns_empty_when_unset() {
8821        // Lines 3178-3183: empty-rules path.
8822        let cfg = AppConfig::default();
8823        let rules = cfg.effective_permission_rules();
8824        assert!(rules.is_empty());
8825    }
8826
8827    #[test]
8828    fn app_config_load_with_no_config_env_returns_default() {
8829        // Lines 3015-3022: `AppConfig::load` with AI_MEMORY_NO_CONFIG=1.
8830        let _g = env_var_lock();
8831        let prior = std::env::var("AI_MEMORY_NO_CONFIG").ok();
8832        unsafe {
8833            std::env::set_var("AI_MEMORY_NO_CONFIG", "1");
8834        }
8835        let cfg = AppConfig::load();
8836        // Default config has no tier/db set.
8837        assert!(
8838            cfg.tier.is_none()
8839                || cfg.tier == Some("semantic".to_string())
8840                || cfg.tier == Some("keyword".to_string())
8841        );
8842        unsafe {
8843            match prior {
8844                Some(v) => std::env::set_var("AI_MEMORY_NO_CONFIG", v),
8845                None => std::env::remove_var("AI_MEMORY_NO_CONFIG"),
8846            }
8847        }
8848    }
8849
8850    // ---- C-5 (#699) round 2: round out the easy Default impls + serde
8851    // default helpers that bumped lines 805/852/955/1019/1057/1125/1634+ ----
8852
8853    #[test]
8854    fn capability_compaction_default_is_planned() {
8855        // Lines 804-808.
8856        let d: CapabilityCompaction = Default::default();
8857        let planned = CapabilityCompaction::planned();
8858        // Compare via Debug since the struct has no PartialEq.
8859        assert_eq!(format!("{d:?}"), format!("{planned:?}"));
8860    }
8861
8862    #[test]
8863    fn capability_transcripts_default_is_planned() {
8864        // Lines 851-855.
8865        let d: CapabilityTranscripts = Default::default();
8866        let planned = CapabilityTranscripts::planned();
8867        assert_eq!(format!("{d:?}"), format!("{planned:?}"));
8868    }
8869
8870    #[test]
8871    fn default_capability_reflection_helper_returns_current() {
8872        // Lines 955-957.
8873        let helper = default_capability_reflection();
8874        let current = CapabilityReflection::current();
8875        assert_eq!(format!("{helper:?}"), format!("{current:?}"));
8876    }
8877
8878    #[test]
8879    fn issue_1672_curator_mode_honest_per_sal_feature() {
8880        // curator --reflect is sal-gated; curator_mode must report the honest
8881        // value for the build feature set, not a blanket "implemented".
8882        let cm = CapabilityReflection::current().curator_mode;
8883        if cfg!(feature = "sal") {
8884            assert_eq!(cm, IMPLEMENTED);
8885        } else {
8886            assert_eq!(cm, CURATOR_MODE_REQUIRES_SAL);
8887        }
8888    }
8889
8890    #[test]
8891    fn default_capability_skills_helper_returns_current() {
8892        // Lines 1019-1021.
8893        let helper = default_capability_skills();
8894        let current = CapabilitySkills::current();
8895        assert_eq!(helper, current);
8896    }
8897
8898    #[test]
8899    fn default_capability_forensic_helper_returns_current() {
8900        // Lines 1057-1059.
8901        let helper = default_capability_forensic();
8902        let current = CapabilityForensic::current();
8903        assert_eq!(helper, current);
8904    }
8905
8906    #[test]
8907    fn default_capability_governance_helper_returns_current() {
8908        // Lines 1125-1127.
8909        let helper = default_capability_governance();
8910        let current = CapabilityGovernance::current();
8911        assert_eq!(helper, current);
8912    }
8913
8914    #[test]
8915    fn default_capability_atomisation_helper_returns_current() {
8916        // v0.7.0 WT-1-G — mirrors the governance/forensic/skills/reflection
8917        // helper round-trip: the `#[serde(default = …)]` resolver must
8918        // collapse to the same compile-anchored snapshot
8919        // [`CapabilityAtomisation::current`] returns.
8920        let helper = default_capability_atomisation();
8921        let current = CapabilityAtomisation::current();
8922        assert_eq!(helper, current);
8923    }
8924
8925    #[test]
8926    fn resolved_transcript_lifecycle_default_uses_compiled_defaults() {
8927        // Lines 1633-1639.
8928        let r: ResolvedTranscriptLifecycle = Default::default();
8929        assert_eq!(r.default_ttl_secs, DEFAULT_TRANSCRIPT_TTL_SECS);
8930        assert_eq!(r.archive_grace_secs, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS);
8931    }
8932
8933    #[test]
8934    fn default_memory_kinds_lists_observation_and_reflection() {
8935        // Lines 626-628: serde default helper covers L1-1 typed kinds.
8936        let kinds = default_memory_kinds();
8937        assert_eq!(
8938            kinds,
8939            vec!["observation".to_string(), "reflection".to_string()]
8940        );
8941    }
8942
8943    /// v0.7.0 Gap 4 (#887) — pin the capabilities-surface thresholds
8944    /// to the `ConfidenceTier` model constants so a future
8945    /// re-tuning bumps BOTH in lockstep (or the build breaks).
8946    #[test]
8947    fn confidence_tier_thresholds_match_model_constants() {
8948        let defaults = ConfidenceTierThresholds::default();
8949        assert!(
8950            (defaults.confirmed - crate::models::ConfidenceTier::CONFIRMED_MIN).abs()
8951                < f64::EPSILON,
8952            "ConfidenceTierThresholds.confirmed must match ConfidenceTier::CONFIRMED_MIN"
8953        );
8954        assert!(
8955            (defaults.likely - crate::models::ConfidenceTier::LIKELY_MIN).abs() < f64::EPSILON,
8956            "ConfidenceTierThresholds.likely must match ConfidenceTier::LIKELY_MIN"
8957        );
8958        // Ambiguous is the implicit floor — pin it to zero so the
8959        // wire shape is fully self-describing.
8960        assert!(
8961            (defaults.ambiguous - 0.0).abs() < f64::EPSILON,
8962            "ambiguous floor is fixed at 0.0"
8963        );
8964    }
8965
8966    /// v0.7.0 Gap 4 (#887) — every `TierConfig::capabilities()` call
8967    /// must surface the calibration block so MCP capability readers
8968    /// can rely on the field being present.
8969    #[test]
8970    fn capability_confidence_calibration_carries_tier_thresholds() {
8971        // `CapabilityConfidenceCalibration::current()` (the
8972        // capabilities v3 builder) surfaces the Gap 4 thresholds so
8973        // MCP capability readers can filter without re-deriving the
8974        // breakpoints.
8975        let surface = CapabilityConfidenceCalibration::current();
8976        assert!((surface.tier_thresholds.confirmed - 0.95).abs() < f64::EPSILON);
8977        assert!((surface.tier_thresholds.likely - 0.7).abs() < f64::EPSILON);
8978        assert!((surface.tier_thresholds.ambiguous - 0.0).abs() < f64::EPSILON);
8979    }
8980
8981    // ---------------------------------------------------------------------
8982    // v0.7.x enterprise-config tests (#1146)
8983    //
8984    // Pin: precedence ladder per resolver (CLI > env > config > legacy >
8985    // compiled), inline-key rejection at parse time, api_key_env /
8986    // api_key_file resolution, Once-gated legacy-drift WARN.
8987    // ---------------------------------------------------------------------
8988
8989    fn empty_app_config() -> AppConfig {
8990        AppConfig {
8991            schema_version: Some(2),
8992            ..AppConfig::default()
8993        }
8994    }
8995
8996    fn scrub_llm_env() {
8997        for k in [
8998            "AI_MEMORY_LLM_BACKEND",
8999            "AI_MEMORY_LLM_MODEL",
9000            "AI_MEMORY_LLM_BASE_URL",
9001            "AI_MEMORY_LLM_API_KEY",
9002            "XAI_API_KEY",
9003            "OPENAI_API_KEY",
9004            "ANTHROPIC_API_KEY",
9005            "GEMINI_API_KEY",
9006            "GOOGLE_API_KEY",
9007            "DEEPSEEK_API_KEY",
9008            "AI_MEMORY_EMBED_BACKFILL_BATCH",
9009            "AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS",
9010        ] {
9011            unsafe {
9012                std::env::remove_var(k);
9013            }
9014        }
9015    }
9016
9017    /// #1598 — scrub the embeddings-resolver env surface (and the
9018    /// alias-fallback vendor key vars the precedence tests exercise)
9019    /// so `resolve_embeddings` tests are hermetic. Callers hold
9020    /// `env_var_lock()`.
9021    fn scrub_embed_env() {
9022        for k in [
9023            ENV_EMBED_BACKEND,
9024            ENV_EMBED_BASE_URL,
9025            ENV_EMBED_MODEL,
9026            ENV_EMBED_API_KEY,
9027            ENV_EMBED_BACKFILL_BATCH,
9028            "OPENROUTER_API_KEY",
9029            "GEMINI_API_KEY",
9030            "GOOGLE_API_KEY",
9031        ] {
9032            unsafe {
9033                std::env::remove_var(k);
9034            }
9035        }
9036    }
9037
9038    fn scrub_limits_env() {
9039        for k in [
9040            ENV_MAX_MEMORIES_PER_DAY,
9041            ENV_MAX_STORAGE_BYTES,
9042            ENV_MAX_LINKS_PER_DAY,
9043            ENV_MAX_PAGE_SIZE,
9044        ] {
9045            unsafe {
9046                std::env::remove_var(k);
9047            }
9048        }
9049    }
9050
9051    #[test]
9052    fn resolve_limits_compiled_default_when_nothing_configured() {
9053        let _g = env_var_lock();
9054        scrub_limits_env();
9055        let cfg = empty_app_config();
9056        let r = cfg.resolve_limits();
9057        assert_eq!(
9058            r.max_memories_per_day,
9059            crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
9060        );
9061        assert_eq!(
9062            r.max_storage_bytes,
9063            crate::quotas::DEFAULT_MAX_STORAGE_BYTES
9064        );
9065        assert_eq!(
9066            r.max_links_per_day,
9067            crate::quotas::DEFAULT_MAX_LINKS_PER_DAY
9068        );
9069        assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
9070        assert_eq!(r.source, ConfigSource::CompiledDefault);
9071    }
9072
9073    #[test]
9074    fn resolve_limits_config_section_when_no_env() {
9075        let _g = env_var_lock();
9076        scrub_limits_env();
9077        let mut cfg = empty_app_config();
9078        cfg.limits = Some(LimitsSection {
9079            max_memories_per_day: Some(5_000_000),
9080            max_storage_bytes: Some(9_000_000_000),
9081            max_links_per_day: Some(4_000_000),
9082            max_page_size: Some(250_000),
9083        });
9084        let r = cfg.resolve_limits();
9085        assert_eq!(r.max_memories_per_day, 5_000_000);
9086        assert_eq!(r.max_storage_bytes, 9_000_000_000);
9087        assert_eq!(r.max_links_per_day, 4_000_000);
9088        assert_eq!(r.max_page_size, 250_000);
9089        assert_eq!(r.source, ConfigSource::Config);
9090    }
9091
9092    #[test]
9093    fn resolve_limits_env_overrides_config_section() {
9094        let _g = env_var_lock();
9095        scrub_limits_env();
9096        unsafe {
9097            std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "7000000");
9098            std::env::set_var(ENV_MAX_PAGE_SIZE, "123456");
9099        }
9100        let mut cfg = empty_app_config();
9101        cfg.limits = Some(LimitsSection {
9102            max_memories_per_day: Some(5_000_000),
9103            max_storage_bytes: Some(9_000_000_000),
9104            max_links_per_day: Some(4_000_000),
9105            max_page_size: Some(250_000),
9106        });
9107        let r = cfg.resolve_limits();
9108        // env wins for the two it sets …
9109        assert_eq!(r.max_memories_per_day, 7_000_000, "env beats config");
9110        assert_eq!(r.max_page_size, 123_456, "env beats config");
9111        // … and config still supplies the fields env left unset.
9112        assert_eq!(r.max_storage_bytes, 9_000_000_000);
9113        assert_eq!(r.max_links_per_day, 4_000_000);
9114        assert_eq!(r.source, ConfigSource::Env);
9115        scrub_limits_env();
9116    }
9117
9118    #[test]
9119    fn resolve_limits_zero_and_garbage_env_fall_through() {
9120        let _g = env_var_lock();
9121        scrub_limits_env();
9122        unsafe {
9123            std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "0"); // non-positive → ignored
9124            std::env::set_var(ENV_MAX_STORAGE_BYTES, "not-a-number"); // unparseable → ignored
9125            std::env::set_var(ENV_MAX_PAGE_SIZE, "-5"); // negative → unparseable as usize → ignored
9126        }
9127        let cfg = empty_app_config();
9128        let r = cfg.resolve_limits();
9129        // every stray env value falls through to the compiled default.
9130        assert_eq!(
9131            r.max_memories_per_day,
9132            crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
9133        );
9134        assert_eq!(
9135            r.max_storage_bytes,
9136            crate::quotas::DEFAULT_MAX_STORAGE_BYTES
9137        );
9138        assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
9139        assert_eq!(r.source, ConfigSource::CompiledDefault);
9140        scrub_limits_env();
9141    }
9142
9143    #[test]
9144    fn resolve_limits_zero_config_value_falls_through_to_default() {
9145        let _g = env_var_lock();
9146        scrub_limits_env();
9147        let mut cfg = empty_app_config();
9148        cfg.limits = Some(LimitsSection {
9149            max_page_size: Some(0), // non-positive → ignored
9150            ..LimitsSection::default()
9151        });
9152        let r = cfg.resolve_limits();
9153        assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
9154        assert_eq!(r.source, ConfigSource::CompiledDefault);
9155    }
9156
9157    #[test]
9158    fn resolve_limits_section_round_trips_through_toml() {
9159        let toml = r#"
9160schema_version = 2
9161
9162[limits]
9163max_memories_per_day = 10000000
9164max_storage_bytes = 50000000000
9165max_links_per_day = 8000000
9166max_page_size = 1000000
9167"#;
9168        let cfg: AppConfig = toml::from_str(toml).expect("parse [limits] toml");
9169        let l = cfg.limits.as_ref().expect("limits section present");
9170        assert_eq!(l.max_memories_per_day, Some(10_000_000));
9171        assert_eq!(l.max_storage_bytes, Some(50_000_000_000));
9172        assert_eq!(l.max_links_per_day, Some(8_000_000));
9173        assert_eq!(l.max_page_size, Some(1_000_000));
9174        // env-free resolve picks up the config values verbatim.
9175        let _g = env_var_lock();
9176        scrub_limits_env();
9177        let r = cfg.resolve_limits();
9178        assert_eq!(r.max_memories_per_day, 10_000_000);
9179        assert_eq!(r.max_page_size, 1_000_000);
9180        assert_eq!(r.source, ConfigSource::Config);
9181    }
9182
9183    #[cfg(feature = "sal")]
9184    fn scrub_pg_pool_env() {
9185        for k in [
9186            ENV_PG_POOL_MAX,
9187            ENV_PG_POOL_MIN,
9188            ENV_PG_ACQUIRE_TIMEOUT_SECS,
9189        ] {
9190            unsafe {
9191                std::env::remove_var(k);
9192            }
9193        }
9194    }
9195
9196    #[cfg(feature = "sal")]
9197    #[test]
9198    fn resolve_pg_pool_compiled_default_when_nothing_configured() {
9199        let _g = env_var_lock();
9200        scrub_pg_pool_env();
9201        let cfg = empty_app_config();
9202        let r = cfg.resolve_pg_pool();
9203        assert_eq!(r, crate::store::PoolConfig::default());
9204    }
9205
9206    #[cfg(feature = "sal")]
9207    #[test]
9208    fn resolve_pg_pool_config_overrides_default() {
9209        let _g = env_var_lock();
9210        scrub_pg_pool_env();
9211        let mut cfg = empty_app_config();
9212        cfg.postgres_pool_max_connections = Some(64);
9213        cfg.postgres_pool_min_connections = Some(8);
9214        cfg.postgres_acquire_timeout_secs = Some(15);
9215        let r = cfg.resolve_pg_pool();
9216        assert_eq!(r.max_connections, 64);
9217        assert_eq!(r.min_connections, 8);
9218        assert_eq!(r.acquire_timeout_secs, 15);
9219    }
9220
9221    #[cfg(feature = "sal")]
9222    #[test]
9223    fn resolve_pg_pool_env_overrides_config() {
9224        let _g = env_var_lock();
9225        scrub_pg_pool_env();
9226        unsafe {
9227            std::env::set_var(ENV_PG_POOL_MAX, "100");
9228            std::env::set_var(ENV_PG_ACQUIRE_TIMEOUT_SECS, "45");
9229        }
9230        let mut cfg = empty_app_config();
9231        cfg.postgres_pool_max_connections = Some(64);
9232        cfg.postgres_pool_min_connections = Some(8);
9233        cfg.postgres_acquire_timeout_secs = Some(15);
9234        let r = cfg.resolve_pg_pool();
9235        // env wins for the two it sets …
9236        assert_eq!(r.max_connections, 100, "env beats config");
9237        assert_eq!(r.acquire_timeout_secs, 45, "env beats config");
9238        // … and config still supplies the field env left unset.
9239        assert_eq!(r.min_connections, 8);
9240        scrub_pg_pool_env();
9241    }
9242
9243    #[cfg(feature = "sal")]
9244    #[test]
9245    fn resolve_pg_pool_zero_and_garbage_fall_through() {
9246        let _g = env_var_lock();
9247        scrub_pg_pool_env();
9248        unsafe {
9249            std::env::set_var(ENV_PG_POOL_MAX, "0"); // non-positive → ignored
9250            std::env::set_var(ENV_PG_POOL_MIN, "not-a-number"); // unparseable → ignored
9251        }
9252        let mut cfg = empty_app_config();
9253        // A zero config value must also fall through, never clamp the pool.
9254        cfg.postgres_acquire_timeout_secs = Some(0);
9255        let r = cfg.resolve_pg_pool();
9256        // every stray value falls through to the compiled default.
9257        assert_eq!(r, crate::store::PoolConfig::default());
9258        scrub_pg_pool_env();
9259    }
9260
9261    #[cfg(feature = "sal")]
9262    #[test]
9263    fn pg_pool_env_const_names_byte_match_documented() {
9264        // Doc-name-match guard: these byte values are documented in
9265        // CLAUDE.md's Environment Variables table + the enterprise
9266        // deployment guide §5.6. Pin the drift so it can never recur.
9267        assert_eq!(ENV_PG_POOL_MAX, "AI_MEMORY_PG_POOL_MAX");
9268        assert_eq!(ENV_PG_POOL_MIN, "AI_MEMORY_PG_POOL_MIN");
9269        assert_eq!(
9270            ENV_PG_ACQUIRE_TIMEOUT_SECS,
9271            "AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS"
9272        );
9273    }
9274
9275    #[test]
9276    fn resolve_llm_1146_compiled_default_when_nothing_configured() {
9277        let _g = env_var_lock();
9278        scrub_llm_env();
9279        let cfg = empty_app_config();
9280        let resolved = cfg.resolve_llm(None, None, None);
9281        assert_eq!(resolved.backend, "ollama");
9282        assert_eq!(resolved.model, "gemma3:4b");
9283        assert_eq!(resolved.base_url, "http://localhost:11434");
9284        assert_eq!(resolved.source, ConfigSource::CompiledDefault);
9285        assert_eq!(resolved.api_key_source, KeySource::None);
9286        assert!(resolved.api_key().is_none());
9287    }
9288
9289    #[test]
9290    fn resolve_llm_1146_env_overrides_config_section() {
9291        let _g = env_var_lock();
9292        scrub_llm_env();
9293        unsafe {
9294            std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
9295            std::env::set_var("AI_MEMORY_LLM_MODEL", "grok-99");
9296            std::env::set_var("AI_MEMORY_LLM_API_KEY", "env-key");
9297        }
9298        let mut cfg = empty_app_config();
9299        cfg.llm = Some(LlmSection {
9300            backend: Some("openai".into()),
9301            model: Some("gpt-4".into()),
9302            ..LlmSection::default()
9303        });
9304        let resolved = cfg.resolve_llm(None, None, None);
9305        assert_eq!(resolved.backend, "xai", "env must beat config");
9306        assert_eq!(resolved.model, "grok-99");
9307        assert_eq!(resolved.source, ConfigSource::Env);
9308        assert_eq!(resolved.api_key_source, KeySource::ProcessEnv);
9309        assert_eq!(resolved.api_key(), Some("env-key"));
9310        scrub_llm_env();
9311    }
9312
9313    #[test]
9314    fn resolve_llm_1146_cli_overrides_env() {
9315        let _g = env_var_lock();
9316        scrub_llm_env();
9317        unsafe {
9318            std::env::set_var("AI_MEMORY_LLM_BACKEND", "ollama");
9319            std::env::set_var("AI_MEMORY_LLM_MODEL", "ollama-model");
9320        }
9321        let cfg = empty_app_config();
9322        let resolved = cfg.resolve_llm(Some("xai"), Some("grok-4.3"), Some("https://x"));
9323        assert_eq!(resolved.backend, "xai", "CLI flag must beat env");
9324        assert_eq!(resolved.model, "grok-4.3");
9325        assert_eq!(resolved.base_url, "https://x");
9326        assert_eq!(resolved.source, ConfigSource::Cli);
9327        scrub_llm_env();
9328    }
9329
9330    #[test]
9331    fn resolve_llm_1146_config_section_when_no_env() {
9332        let _g = env_var_lock();
9333        scrub_llm_env();
9334        let mut cfg = empty_app_config();
9335        cfg.llm = Some(LlmSection {
9336            backend: Some("xai".into()),
9337            model: Some("grok-4.3".into()),
9338            ..LlmSection::default()
9339        });
9340        let resolved = cfg.resolve_llm(None, None, None);
9341        assert_eq!(resolved.backend, "xai");
9342        assert_eq!(resolved.model, "grok-4.3");
9343        assert_eq!(
9344            resolved.base_url, "https://api.x.ai/v1",
9345            "vendor-default base_url applied"
9346        );
9347        assert_eq!(resolved.source, ConfigSource::Config);
9348    }
9349
9350    #[test]
9351    fn resolve_llm_1146_tier_model_override_clobbers_config_model_1440() {
9352        // #1440 regression: the pre-fix curator `--daemon` path passed
9353        // the feature-tier's default (local-Ollama) model id as the
9354        // CLI-arm model override. Because the CLI arm is highest
9355        // precedence, it clobbered the operator's configured
9356        // `[llm].model`, sending the local default to OpenRouter ->
9357        // fast HTTP 400 on every curator call. This test pins BOTH
9358        // halves of the RCA so the bug can't silently return:
9359        //   1. With no override (the `--once` / fixed `--daemon` path),
9360        //      the configured model wins.
9361        //   2. Passing the tier-default id as the override DOES clobber
9362        //      it — which is exactly why the daemon must never do so.
9363        let _g = env_var_lock();
9364        scrub_llm_env();
9365
9366        // Each value is bound once to a named variable (no repeated
9367        // literals, no magic strings in assertions). The tier-default
9368        // model is derived from the enum so the test tracks the single
9369        // source of truth rather than asserting against a copy.
9370        let configured_backend = "openrouter";
9371        let configured_model = "google/gemma-4-26b-a4b-it";
9372        let tier_default_model = crate::config::FeatureTier::Autonomous.config().llm_model;
9373
9374        let mut cfg = empty_app_config();
9375        cfg.llm = Some(LlmSection {
9376            backend: Some(configured_backend.into()),
9377            model: Some(configured_model.into()),
9378            ..LlmSection::default()
9379        });
9380
9381        // 1. No override -> configured model is honored.
9382        let resolved = cfg.resolve_llm(None, None, None);
9383        assert_eq!(resolved.backend, configured_backend);
9384        assert_eq!(resolved.model, configured_model);
9385
9386        // 2. Tier-default id as CLI-arm override clobbers it (the bug):
9387        //    the override wins over the configured model, which is
9388        //    exactly why the daemon must never manufacture one.
9389        let tier_override = tier_default_model.expect("autonomous tier has a default llm_model");
9390        let clobbered = cfg.resolve_llm(None, Some(tier_override.as_str()), None);
9391        assert_eq!(
9392            clobbered.model, tier_override,
9393            "tier-default override wins over configured model — the #1440 daemon defect"
9394        );
9395        assert_ne!(
9396            clobbered.model, configured_model,
9397            "the override must differ from the configured model for this regression to be meaningful"
9398        );
9399        scrub_llm_env();
9400    }
9401
9402    #[test]
9403    fn resolve_llm_1146_alias_fallback_key_for_xai() {
9404        let _g = env_var_lock();
9405        scrub_llm_env();
9406        unsafe {
9407            std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
9408            std::env::set_var("XAI_API_KEY", "alias-fallback-key");
9409        }
9410        let cfg = empty_app_config();
9411        let resolved = cfg.resolve_llm(None, None, None);
9412        assert_eq!(resolved.backend, "xai");
9413        assert_eq!(resolved.api_key(), Some("alias-fallback-key"));
9414        match &resolved.api_key_source {
9415            KeySource::AliasFallback(name) => assert_eq!(name, "XAI_API_KEY"),
9416            other => panic!("expected AliasFallback(XAI_API_KEY), got {other:?}"),
9417        }
9418        scrub_llm_env();
9419    }
9420
9421    #[test]
9422    fn resolve_llm_1146_legacy_llm_model_feeds_resolver() {
9423        let _g = env_var_lock();
9424        scrub_llm_env();
9425        let mut cfg = AppConfig::default();
9426        cfg.llm_model = Some("gemma4:e4b".into());
9427        cfg.ollama_url = Some("http://localhost:11434".into());
9428        let resolved = cfg.resolve_llm(None, None, None);
9429        assert_eq!(resolved.backend, "ollama");
9430        assert_eq!(resolved.model, "gemma4:e4b");
9431        assert_eq!(resolved.source, ConfigSource::Legacy);
9432    }
9433
9434    #[test]
9435    fn validate_secret_handling_1146_rejects_inline_api_key() {
9436        let mut cfg = empty_app_config();
9437        cfg.llm = Some(LlmSection {
9438            backend: Some("xai".into()),
9439            api_key: Some("xai-INLINE-SECRET".into()),
9440            ..LlmSection::default()
9441        });
9442        let err = cfg
9443            .validate_secret_handling()
9444            .expect_err("inline api_key must be rejected");
9445        assert!(
9446            err.contains("api_key") && err.contains("forbidden"),
9447            "error must name the field and the policy: {err}"
9448        );
9449    }
9450
9451    #[test]
9452    fn validate_secret_handling_1146_rejects_env_and_file_both_set() {
9453        let mut cfg = empty_app_config();
9454        cfg.llm = Some(LlmSection {
9455            backend: Some("xai".into()),
9456            api_key_env: Some("XAI_API_KEY".into()),
9457            api_key_file: Some("/etc/key".into()),
9458            ..LlmSection::default()
9459        });
9460        let err = cfg
9461            .validate_secret_handling()
9462            .expect_err("env+file mutex must be enforced");
9463        assert!(
9464            err.contains("api_key_env") && err.contains("api_key_file"),
9465            "error must call out the mutex: {err}"
9466        );
9467    }
9468
9469    #[test]
9470    fn resolve_llm_1146_api_key_env_reads_named_env_var() {
9471        let _g = env_var_lock();
9472        scrub_llm_env();
9473        unsafe {
9474            std::env::set_var("MY_CUSTOM_LLM_KEY", "via-config-env-var");
9475        }
9476        let mut cfg = empty_app_config();
9477        cfg.llm = Some(LlmSection {
9478            backend: Some("xai".into()),
9479            model: Some("grok-4.3".into()),
9480            api_key_env: Some("MY_CUSTOM_LLM_KEY".into()),
9481            ..LlmSection::default()
9482        });
9483        let resolved = cfg.resolve_llm(None, None, None);
9484        assert_eq!(resolved.api_key(), Some("via-config-env-var"));
9485        match &resolved.api_key_source {
9486            KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_LLM_KEY"),
9487            other => panic!("expected ConfigEnvVar(MY_CUSTOM_LLM_KEY), got {other:?}"),
9488        }
9489        unsafe {
9490            std::env::remove_var("MY_CUSTOM_LLM_KEY");
9491        }
9492    }
9493
9494    #[test]
9495    #[cfg(unix)]
9496    fn resolve_llm_1146_api_key_file_rejects_lax_perms() {
9497        use std::os::unix::fs::PermissionsExt;
9498        let _g = env_var_lock();
9499        scrub_llm_env();
9500        // Tempdir under .local-runs (project HARD rule: no /tmp).
9501        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9502            .join(".local-runs")
9503            .join(format!("test-1146-perms-{}", std::process::id()));
9504        std::fs::create_dir_all(&base).unwrap();
9505        let key_path = base.join("xai.key");
9506        std::fs::write(&key_path, "shhh").unwrap();
9507        // World-readable mode 0644 — must be rejected.
9508        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
9509
9510        let mut cfg = empty_app_config();
9511        cfg.llm = Some(LlmSection {
9512            backend: Some("xai".into()),
9513            api_key_file: Some(key_path.display().to_string()),
9514            ..LlmSection::default()
9515        });
9516        let resolved = cfg.resolve_llm(None, None, None);
9517        match &resolved.api_key_source {
9518            KeySource::Error(reason) => {
9519                assert!(
9520                    reason.contains("lax permissions") && reason.contains("0400"),
9521                    "error must name the perm policy: {reason}"
9522                );
9523            }
9524            other => panic!("expected KeySource::Error(lax perms), got {other:?}"),
9525        }
9526        // Cleanup.
9527        let _ = std::fs::remove_file(&key_path);
9528        let _ = std::fs::remove_dir(&base);
9529    }
9530
9531    #[test]
9532    #[cfg(unix)]
9533    fn resolve_llm_1146_api_key_file_accepts_0400() {
9534        use std::os::unix::fs::PermissionsExt;
9535        let _g = env_var_lock();
9536        scrub_llm_env();
9537        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9538            .join(".local-runs")
9539            .join(format!("test-1146-perms-ok-{}", std::process::id()));
9540        std::fs::create_dir_all(&base).unwrap();
9541        let key_path = base.join("xai.key");
9542        std::fs::write(&key_path, "the-actual-key\n").unwrap();
9543        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o400)).unwrap();
9544
9545        let mut cfg = empty_app_config();
9546        cfg.llm = Some(LlmSection {
9547            backend: Some("xai".into()),
9548            api_key_file: Some(key_path.display().to_string()),
9549            ..LlmSection::default()
9550        });
9551        let resolved = cfg.resolve_llm(None, None, None);
9552        assert_eq!(
9553            resolved.api_key(),
9554            Some("the-actual-key"),
9555            "first line is the key"
9556        );
9557        assert!(matches!(resolved.api_key_source, KeySource::ConfigFile(_)));
9558
9559        let _ = std::fs::remove_file(&key_path);
9560        let _ = std::fs::remove_dir(&base);
9561    }
9562
9563    #[test]
9564    fn resolve_embeddings_1146_legacy_alias_canonicalised() {
9565        let _g = env_var_lock();
9566        scrub_llm_env();
9567        let mut cfg = AppConfig::default();
9568        cfg.embedding_model = Some("nomic_embed_v15".into());
9569        let resolved = cfg.resolve_embeddings();
9570        assert_eq!(
9571            resolved.model, "nomic-embed-text-v1.5",
9572            "legacy alias must be canonicalised"
9573        );
9574        assert_eq!(resolved.source, ConfigSource::Legacy);
9575        assert_eq!(resolved.backfill_batch, 100, "compiled default applied");
9576    }
9577
9578    #[test]
9579    fn resolve_embeddings_1146_backfill_batch_env_overrides_config() {
9580        let _g = env_var_lock();
9581        scrub_llm_env();
9582        unsafe {
9583            std::env::set_var("AI_MEMORY_EMBED_BACKFILL_BATCH", "500");
9584        }
9585        let mut cfg = empty_app_config();
9586        cfg.embeddings = Some(EmbeddingsSection {
9587            backfill_batch: Some(50),
9588            ..EmbeddingsSection::default()
9589        });
9590        let resolved = cfg.resolve_embeddings();
9591        assert_eq!(resolved.backfill_batch, 500, "env must beat config");
9592        scrub_llm_env();
9593    }
9594
9595    // ── #1598 — API-wired embeddings resolver ladder ──────────────────
9596
9597    #[test]
9598    fn resolve_embeddings_1598_compiled_defaults() {
9599        let _g = env_var_lock();
9600        scrub_llm_env();
9601        scrub_embed_env();
9602        let cfg = empty_app_config();
9603        let resolved = cfg.resolve_embeddings();
9604        assert_eq!(resolved.backend, crate::llm::BACKEND_OLLAMA);
9605        assert_eq!(resolved.url, crate::llm::DEFAULT_OLLAMA_URL);
9606        assert_eq!(resolved.model, DEFAULT_EMBED_MODEL);
9607        assert_eq!(resolved.source, ConfigSource::CompiledDefault);
9608        assert_eq!(resolved.api_key(), None);
9609        assert_eq!(resolved.key_source, KeySource::None);
9610    }
9611
9612    #[test]
9613    fn resolve_embeddings_1598_env_beats_section() {
9614        let _g = env_var_lock();
9615        scrub_llm_env();
9616        scrub_embed_env();
9617        unsafe {
9618            std::env::set_var(ENV_EMBED_BACKEND, "openai-compatible");
9619            std::env::set_var(ENV_EMBED_BASE_URL, "http://tei.internal:8080/v1");
9620            std::env::set_var(
9621                ENV_EMBED_MODEL,
9622                "ibm-granite/granite-embedding-125m-english",
9623            );
9624        }
9625        let mut cfg = empty_app_config();
9626        cfg.embeddings = Some(EmbeddingsSection {
9627            backend: Some("ollama".into()),
9628            url: Some("http://section-url:11434".into()),
9629            model: Some("nomic-embed-text-v1.5".into()),
9630            ..EmbeddingsSection::default()
9631        });
9632        let resolved = cfg.resolve_embeddings();
9633        assert_eq!(resolved.backend, "openai-compatible");
9634        assert_eq!(resolved.url, "http://tei.internal:8080/v1");
9635        assert_eq!(resolved.model, "ibm-granite/granite-embedding-125m-english");
9636        assert_eq!(resolved.source, ConfigSource::Env);
9637        assert_eq!(
9638            resolved.embedding_dim,
9639            Some(768),
9640            "granite dim comes from the known-dims table"
9641        );
9642        scrub_embed_env();
9643    }
9644
9645    #[test]
9646    fn resolve_embeddings_1598_section_beats_legacy() {
9647        let _g = env_var_lock();
9648        scrub_llm_env();
9649        scrub_embed_env();
9650        let mut cfg = empty_app_config();
9651        cfg.embed_url = Some("http://legacy-embed:11434".into());
9652        cfg.embedding_model = Some("mini_lm_l6_v2".into());
9653        cfg.embeddings = Some(EmbeddingsSection {
9654            url: Some("http://section:11434".into()),
9655            model: Some("nomic-embed-text-v1.5".into()),
9656            ..EmbeddingsSection::default()
9657        });
9658        let resolved = cfg.resolve_embeddings();
9659        assert_eq!(resolved.url, "http://section:11434");
9660        assert_eq!(resolved.model, "nomic-embed-text-v1.5");
9661        assert_eq!(resolved.source, ConfigSource::Config);
9662    }
9663
9664    #[test]
9665    fn resolve_embeddings_1598_base_url_wins_over_url_synonym() {
9666        let _g = env_var_lock();
9667        scrub_llm_env();
9668        scrub_embed_env();
9669        let mut cfg = empty_app_config();
9670        cfg.embeddings = Some(EmbeddingsSection {
9671            base_url: Some("http://base-url-wins:8080/v1".into()),
9672            url: Some("http://url-loses:11434".into()),
9673            ..EmbeddingsSection::default()
9674        });
9675        let resolved = cfg.resolve_embeddings();
9676        assert_eq!(resolved.url, "http://base-url-wins:8080/v1");
9677    }
9678
9679    #[test]
9680    fn resolve_embeddings_1598_api_alias_default_base_url() {
9681        let _g = env_var_lock();
9682        scrub_llm_env();
9683        scrub_embed_env();
9684        let mut cfg = empty_app_config();
9685        cfg.embeddings = Some(EmbeddingsSection {
9686            backend: Some("openrouter".into()),
9687            model: Some("google/gemini-embedding-2".into()),
9688            ..EmbeddingsSection::default()
9689        });
9690        let resolved = cfg.resolve_embeddings();
9691        assert_eq!(
9692            resolved.url, "https://openrouter.ai/api/v1",
9693            "API alias with no URL configured must fall back to the \
9694             vendor default from llm.rs"
9695        );
9696        assert_eq!(resolved.embedding_dim, Some(3072), "gemini-embedding-2 dim");
9697    }
9698
9699    #[test]
9700    fn resolve_embeddings_1598_dim_override_beats_table() {
9701        let _g = env_var_lock();
9702        scrub_llm_env();
9703        scrub_embed_env();
9704        let mut cfg = empty_app_config();
9705        cfg.embeddings = Some(EmbeddingsSection {
9706            model: Some("nomic-embed-text-v1.5".into()),
9707            dim: Some(512),
9708            ..EmbeddingsSection::default()
9709        });
9710        let resolved = cfg.resolve_embeddings();
9711        assert_eq!(
9712            resolved.embedding_dim,
9713            Some(512),
9714            "[embeddings].dim override must beat the known-dims table"
9715        );
9716        // Non-positive override is ignored — table wins again.
9717        cfg.embeddings = Some(EmbeddingsSection {
9718            model: Some("nomic-embed-text-v1.5".into()),
9719            dim: Some(0),
9720            ..EmbeddingsSection::default()
9721        });
9722        assert_eq!(cfg.resolve_embeddings().embedding_dim, Some(768));
9723    }
9724
9725    /// #1598 fleet follow-up — `requested_dim` carries ONLY the
9726    /// explicit `[embeddings].dim` (the wire `dimensions` request for
9727    /// Matryoshka-capable API models); a table-derived dim must never
9728    /// populate it, and non-positive overrides are ignored.
9729    #[test]
9730    fn resolve_embeddings_1598_requested_dim_explicit_only() {
9731        let _g = env_var_lock();
9732        scrub_llm_env();
9733        scrub_embed_env();
9734        let mut cfg = empty_app_config();
9735        // Table-known model, no explicit dim → requested_dim None.
9736        cfg.embeddings = Some(EmbeddingsSection {
9737            model: Some("nomic-embed-text-v1.5".into()),
9738            ..EmbeddingsSection::default()
9739        });
9740        let resolved = cfg.resolve_embeddings();
9741        assert_eq!(resolved.embedding_dim, Some(768), "table dim resolves");
9742        assert_eq!(
9743            resolved.requested_dim, None,
9744            "table-derived dim must not become a wire dimensions request"
9745        );
9746        // Explicit dim → both embedding_dim and requested_dim.
9747        cfg.embeddings = Some(EmbeddingsSection {
9748            model: Some("google/gemini-embedding-2".into()),
9749            dim: Some(768),
9750            ..EmbeddingsSection::default()
9751        });
9752        let resolved = cfg.resolve_embeddings();
9753        assert_eq!(resolved.embedding_dim, Some(768));
9754        assert_eq!(resolved.requested_dim, Some(768));
9755        // Non-positive explicit dim is ignored on both fields.
9756        cfg.embeddings = Some(EmbeddingsSection {
9757            model: Some("google/gemini-embedding-2".into()),
9758            dim: Some(0),
9759            ..EmbeddingsSection::default()
9760        });
9761        let resolved = cfg.resolve_embeddings();
9762        assert_eq!(resolved.embedding_dim, Some(3072), "table dim again");
9763        assert_eq!(resolved.requested_dim, None);
9764    }
9765
9766    #[test]
9767    fn resolve_embed_api_key_1598_process_env_wins() {
9768        let _g = env_var_lock();
9769        scrub_llm_env();
9770        scrub_embed_env();
9771        unsafe {
9772            std::env::set_var(ENV_EMBED_API_KEY, "embed-process-env-key");
9773            std::env::set_var("OPENROUTER_API_KEY", "alias-key-loses");
9774        }
9775        let mut cfg = empty_app_config();
9776        cfg.embeddings = Some(EmbeddingsSection {
9777            backend: Some("openrouter".into()),
9778            ..EmbeddingsSection::default()
9779        });
9780        let resolved = cfg.resolve_embeddings();
9781        assert_eq!(resolved.api_key(), Some("embed-process-env-key"));
9782        assert_eq!(resolved.key_source, KeySource::ProcessEnv);
9783        scrub_embed_env();
9784    }
9785
9786    #[test]
9787    fn resolve_embed_api_key_1598_alias_fallback() {
9788        let _g = env_var_lock();
9789        scrub_llm_env();
9790        scrub_embed_env();
9791        unsafe {
9792            std::env::set_var("OPENROUTER_API_KEY", "alias-fallback-embed-key");
9793        }
9794        let mut cfg = empty_app_config();
9795        cfg.embeddings = Some(EmbeddingsSection {
9796            backend: Some("openrouter".into()),
9797            ..EmbeddingsSection::default()
9798        });
9799        let resolved = cfg.resolve_embeddings();
9800        assert_eq!(resolved.api_key(), Some("alias-fallback-embed-key"));
9801        match &resolved.key_source {
9802            KeySource::AliasFallback(name) => assert_eq!(name, "OPENROUTER_API_KEY"),
9803            other => panic!("expected AliasFallback(OPENROUTER_API_KEY), got {other:?}"),
9804        }
9805        scrub_embed_env();
9806    }
9807
9808    #[test]
9809    fn resolve_embed_api_key_1598_config_env_var() {
9810        let _g = env_var_lock();
9811        scrub_llm_env();
9812        scrub_embed_env();
9813        unsafe {
9814            std::env::set_var("MY_CUSTOM_EMBED_KEY", "via-embed-config-env-var");
9815        }
9816        let mut cfg = empty_app_config();
9817        cfg.embeddings = Some(EmbeddingsSection {
9818            backend: Some("openai-compatible".into()),
9819            api_key_env: Some("MY_CUSTOM_EMBED_KEY".into()),
9820            ..EmbeddingsSection::default()
9821        });
9822        let resolved = cfg.resolve_embeddings();
9823        assert_eq!(resolved.api_key(), Some("via-embed-config-env-var"));
9824        match &resolved.key_source {
9825            KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_EMBED_KEY"),
9826            other => panic!("expected ConfigEnvVar(MY_CUSTOM_EMBED_KEY), got {other:?}"),
9827        }
9828        unsafe {
9829            std::env::remove_var("MY_CUSTOM_EMBED_KEY");
9830        }
9831    }
9832
9833    #[test]
9834    #[cfg(unix)]
9835    fn resolve_embed_api_key_1598_api_key_file_rejects_lax_perms() {
9836        use std::os::unix::fs::PermissionsExt;
9837        let _g = env_var_lock();
9838        scrub_llm_env();
9839        scrub_embed_env();
9840        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9841            .join(".local-runs")
9842            .join(format!("test-1598-perms-lax-{}", std::process::id()));
9843        std::fs::create_dir_all(&base).unwrap();
9844        let key_path = base.join("embed.key");
9845        std::fs::write(&key_path, "leaky-embed-key\n").unwrap();
9846        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
9847
9848        let mut cfg = empty_app_config();
9849        cfg.embeddings = Some(EmbeddingsSection {
9850            backend: Some("openai-compatible".into()),
9851            api_key_file: Some(key_path.display().to_string()),
9852            ..EmbeddingsSection::default()
9853        });
9854        let resolved = cfg.resolve_embeddings();
9855        assert_eq!(resolved.api_key(), None, "lax-perm file must be refused");
9856        match &resolved.key_source {
9857            KeySource::Error(reason) => {
9858                assert!(
9859                    reason.contains("[embeddings].api_key_file") && reason.contains("lax"),
9860                    "error must attribute the embeddings field: {reason}"
9861                );
9862            }
9863            other => panic!("expected KeySource::Error, got {other:?}"),
9864        }
9865
9866        let _ = std::fs::remove_file(&key_path);
9867        let _ = std::fs::remove_dir(&base);
9868    }
9869
9870    #[test]
9871    fn resolved_embeddings_1598_debug_redacts_api_key() {
9872        let _g = env_var_lock();
9873        scrub_llm_env();
9874        scrub_embed_env();
9875        unsafe {
9876            std::env::set_var(ENV_EMBED_API_KEY, "super-secret-embed-key");
9877        }
9878        let mut cfg = empty_app_config();
9879        cfg.embeddings = Some(EmbeddingsSection {
9880            backend: Some("openrouter".into()),
9881            ..EmbeddingsSection::default()
9882        });
9883        let resolved = cfg.resolve_embeddings();
9884        let debugged = format!("{resolved:?}");
9885        assert!(
9886            !debugged.contains("super-secret-embed-key"),
9887            "Debug must never leak the key: {debugged}"
9888        );
9889        assert!(
9890            debugged.contains(crate::REDACTED_PLACEHOLDER),
9891            "Debug must show the redaction placeholder: {debugged}"
9892        );
9893        scrub_embed_env();
9894    }
9895
9896    #[test]
9897    fn validate_secret_handling_1598_rejects_inline_embeddings_api_key() {
9898        let mut cfg = empty_app_config();
9899        cfg.embeddings = Some(EmbeddingsSection {
9900            backend: Some("openrouter".into()),
9901            api_key: Some("embed-INLINE-SECRET".into()),
9902            ..EmbeddingsSection::default()
9903        });
9904        let err = cfg
9905            .validate_secret_handling()
9906            .expect_err("inline [embeddings].api_key must be rejected");
9907        assert!(
9908            err.contains("api_key") && err.contains("forbidden") && err.contains("[embeddings]"),
9909            "error must name the field, section, and policy: {err}"
9910        );
9911    }
9912
9913    #[test]
9914    fn validate_secret_handling_1598_rejects_embeddings_env_and_file_both_set() {
9915        let mut cfg = empty_app_config();
9916        cfg.embeddings = Some(EmbeddingsSection {
9917            api_key_env: Some("EMBED_KEY".into()),
9918            api_key_file: Some("/etc/embed.key".into()),
9919            ..EmbeddingsSection::default()
9920        });
9921        let err = cfg
9922            .validate_secret_handling()
9923            .expect_err("[embeddings] env+file mutex must be enforced");
9924        assert!(
9925            err.contains("[embeddings].api_key_env") && err.contains("[embeddings].api_key_file"),
9926            "error must call out the mutex: {err}"
9927        );
9928    }
9929
9930    #[test]
9931    fn is_api_embed_backend_1598_classification() {
9932        // "ollama" is the ONLY non-API backend (case/space tolerant).
9933        assert!(!is_api_embed_backend(crate::llm::BACKEND_OLLAMA));
9934        assert!(!is_api_embed_backend(" Ollama "));
9935        // Every #1067 alias + the generic escape hatch is an API backend.
9936        for api in ["openrouter", "openai", "gemini", "openai-compatible"] {
9937            assert!(is_api_embed_backend(api), "{api} must classify as API");
9938        }
9939    }
9940
9941    #[test]
9942    fn known_embedding_dims_1598_gemini_and_granite_entries() {
9943        assert_eq!(
9944            canonical_embedding_dim("google/gemini-embedding-2"),
9945            Some(3072)
9946        );
9947        assert_eq!(canonical_embedding_dim("gemini-embedding-2"), Some(3072));
9948        assert_eq!(
9949            canonical_embedding_dim("ibm-granite/granite-embedding-125m-english"),
9950            Some(768)
9951        );
9952        assert_eq!(canonical_embedding_dim("granite-embedding"), Some(768));
9953    }
9954
9955    // ── #1579 B7 — `[storage].db_mmap_size_bytes` / AI_MEMORY_DB_MMAP_SIZE ──
9956
9957    #[test]
9958    fn resolve_storage_1579_mmap_compiled_default() {
9959        let _g = env_var_lock();
9960        unsafe {
9961            std::env::remove_var(ENV_DB_MMAP_SIZE);
9962        }
9963        let cfg = empty_app_config();
9964        let resolved = cfg.resolve_storage();
9965        assert_eq!(
9966            resolved.db_mmap_size_bytes,
9967            crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
9968            "no env + no section must bottom out on the compiled 256 MiB default"
9969        );
9970    }
9971
9972    #[test]
9973    fn resolve_storage_1579_mmap_env_overrides_config() {
9974        let _g = env_var_lock();
9975        unsafe {
9976            std::env::set_var(ENV_DB_MMAP_SIZE, "1048576");
9977        }
9978        let mut cfg = empty_app_config();
9979        cfg.storage = Some(StorageSection {
9980            db_mmap_size_bytes: Some(2_097_152),
9981            ..StorageSection::default()
9982        });
9983        let resolved = cfg.resolve_storage();
9984        assert_eq!(
9985            resolved.db_mmap_size_bytes, 1_048_576,
9986            "env must beat the [storage] section"
9987        );
9988        unsafe {
9989            std::env::remove_var(ENV_DB_MMAP_SIZE);
9990        }
9991    }
9992
9993    #[test]
9994    fn resolve_storage_1579_mmap_config_zero_disables() {
9995        let _g = env_var_lock();
9996        unsafe {
9997            std::env::remove_var(ENV_DB_MMAP_SIZE);
9998        }
9999        let mut cfg = empty_app_config();
10000        cfg.storage = Some(StorageSection {
10001            db_mmap_size_bytes: Some(0),
10002            ..StorageSection::default()
10003        });
10004        let resolved = cfg.resolve_storage();
10005        assert_eq!(
10006            resolved.db_mmap_size_bytes, 0,
10007            "explicit 0 (mmap disabled) is a deliberate operator choice and must be honoured"
10008        );
10009    }
10010
10011    #[test]
10012    fn resolve_storage_1579_mmap_garbage_falls_through() {
10013        let _g = env_var_lock();
10014        unsafe {
10015            std::env::set_var(ENV_DB_MMAP_SIZE, "not-a-number");
10016        }
10017        let mut cfg = empty_app_config();
10018        cfg.storage = Some(StorageSection {
10019            db_mmap_size_bytes: Some(-5),
10020            ..StorageSection::default()
10021        });
10022        let resolved = cfg.resolve_storage();
10023        assert_eq!(
10024            resolved.db_mmap_size_bytes,
10025            crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
10026            "unparseable env + negative section value must both fall through to the compiled default"
10027        );
10028        unsafe {
10029            std::env::remove_var(ENV_DB_MMAP_SIZE);
10030        }
10031    }
10032
10033    // ── #1590 — `[storage].default_namespace` explicit-vs-compiled provenance ──
10034
10035    /// #1590 regression — `resolve_storage` distinguishes an EXPLICIT
10036    /// operator `default_namespace` (section or legacy flat field)
10037    /// from the compiled `"global"` fallback, and
10038    /// `explicit_default_namespace()` only reports the former.
10039    #[test]
10040    fn resolve_storage_default_namespace_provenance_1590() {
10041        let _g = env_var_lock();
10042        // Unconfigured: compiled default, NOT explicit.
10043        let cfg = empty_app_config();
10044        let resolved = cfg.resolve_storage();
10045        assert_eq!(resolved.default_namespace, crate::DEFAULT_NAMESPACE);
10046        assert_eq!(
10047            resolved.default_namespace_source,
10048            ConfigSource::CompiledDefault
10049        );
10050        assert_eq!(resolved.explicit_default_namespace(), None);
10051
10052        // A [storage] section WITHOUT default_namespace is still NOT
10053        // explicit (the section-level `source` tag says Config, which
10054        // is exactly why the per-field tag exists).
10055        let mut cfg = empty_app_config();
10056        cfg.storage = Some(StorageSection {
10057            archive_on_gc: Some(true),
10058            ..StorageSection::default()
10059        });
10060        let resolved = cfg.resolve_storage();
10061        assert_eq!(resolved.explicit_default_namespace(), None);
10062        assert_eq!(
10063            resolved.default_namespace_source,
10064            ConfigSource::CompiledDefault
10065        );
10066
10067        // Explicit [storage].default_namespace → Config provenance.
10068        let mut cfg = empty_app_config();
10069        cfg.storage = Some(StorageSection {
10070            default_namespace: Some("alphaone".to_string()),
10071            ..StorageSection::default()
10072        });
10073        let resolved = cfg.resolve_storage();
10074        assert_eq!(resolved.default_namespace, "alphaone");
10075        assert_eq!(resolved.default_namespace_source, ConfigSource::Config);
10076        assert_eq!(resolved.explicit_default_namespace(), Some("alphaone"));
10077
10078        // Legacy flat field → Legacy provenance, still explicit.
10079        #[allow(deprecated)]
10080        let resolved = {
10081            let mut cfg = empty_app_config();
10082            cfg.default_namespace = Some("legacy-ns".to_string());
10083            cfg.resolve_storage()
10084        };
10085        assert_eq!(resolved.default_namespace, "legacy-ns");
10086        assert_eq!(resolved.default_namespace_source, ConfigSource::Legacy);
10087        assert_eq!(resolved.explicit_default_namespace(), Some("legacy-ns"));
10088
10089        // Whitespace-only is treated as unset (not explicit).
10090        let mut cfg = empty_app_config();
10091        cfg.storage = Some(StorageSection {
10092            default_namespace: Some("   ".to_string()),
10093            ..StorageSection::default()
10094        });
10095        let resolved = cfg.resolve_storage();
10096        assert_eq!(resolved.explicit_default_namespace(), None);
10097    }
10098
10099    /// #1590 regression — the process-wide seeded slot round-trips,
10100    /// filters blank values, and clears back to the unconfigured state.
10101    #[test]
10102    fn configured_default_namespace_seed_and_clear_1590() {
10103        let _gate = lock_configured_default_namespace_for_test();
10104        set_configured_default_namespace(Some("alphaone".to_string()));
10105        assert_eq!(
10106            configured_default_namespace().as_deref(),
10107            Some("alphaone"),
10108            "seeded value must be readable process-wide"
10109        );
10110        set_configured_default_namespace(Some("  ".to_string()));
10111        assert_eq!(
10112            configured_default_namespace(),
10113            None,
10114            "blank seeds are filtered to the unconfigured state"
10115        );
10116        set_configured_default_namespace(Some("ns2".to_string()));
10117        set_configured_default_namespace(None);
10118        assert_eq!(configured_default_namespace(), None, "clear resets");
10119    }
10120
10121    #[test]
10122    fn resolve_reranker_1146_folds_legacy_cross_encoder() {
10123        let _g = env_var_lock();
10124        let mut cfg = AppConfig::default();
10125        cfg.cross_encoder = Some(true);
10126        let resolved = cfg.resolve_reranker();
10127        assert!(resolved.enabled);
10128        assert_eq!(resolved.model, "ms-marco-MiniLM-L-6-v2");
10129        assert_eq!(resolved.source, ConfigSource::Legacy);
10130    }
10131
10132    #[test]
10133    fn curator_reflection_namespace_enabled_1671() {
10134        use std::collections::HashMap;
10135        // No [curator] section → conservative default false (the
10136        // pre-#1671 inert-but-safe --all-namespaces posture).
10137        let bare = AppConfig::default();
10138        assert!(!bare.reflection_namespace_enabled("team/eng"));
10139
10140        let mut ns_map = HashMap::new();
10141        ns_map.insert(
10142            "team/eng".to_string(),
10143            crate::curator::reflection_pass::ReflectionPassConfig {
10144                enabled: true,
10145                max_depth: None,
10146            },
10147        );
10148        ns_map.insert(
10149            "team/ops".to_string(),
10150            crate::curator::reflection_pass::ReflectionPassConfig {
10151                enabled: false,
10152                max_depth: None,
10153            },
10154        );
10155        let cfg = AppConfig {
10156            curator: Some(CuratorSection {
10157                reflection_namespaces: Some(ns_map),
10158                confidence_decay_half_life_days: None,
10159            }),
10160            ..AppConfig::default()
10161        };
10162        assert!(
10163            cfg.reflection_namespace_enabled("team/eng"),
10164            "#1671: enabled=true namespace participates"
10165        );
10166        assert!(
10167            !cfg.reflection_namespace_enabled("team/ops"),
10168            "#1671: enabled=false namespace is skipped"
10169        );
10170        assert!(
10171            !cfg.reflection_namespace_enabled("team/unlisted"),
10172            "#1671: namespace with no entry is skipped"
10173        );
10174    }
10175
10176    #[test]
10177    fn curator_confidence_decay_half_life_resolver_n15() {
10178        use std::collections::HashMap;
10179        // No config → compiled default.
10180        let bare = AppConfig::default();
10181        assert!(
10182            (bare.confidence_decay_half_life_for("team/eng")
10183                - crate::confidence::DEFAULT_HALF_LIFE_DAYS)
10184                .abs()
10185                < f64::EPSILON
10186        );
10187
10188        let mut hl = HashMap::new();
10189        hl.insert("team/eng".to_string(), 14.0_f64);
10190        hl.insert("team/bad".to_string(), -5.0_f64); // non-positive → falls through
10191        hl.insert("team/nan".to_string(), f64::NAN); // non-finite → falls through
10192        let cfg = AppConfig {
10193            curator: Some(CuratorSection {
10194                reflection_namespaces: None,
10195                confidence_decay_half_life_days: Some(hl),
10196            }),
10197            ..AppConfig::default()
10198        };
10199        assert!((cfg.confidence_decay_half_life_for("team/eng") - 14.0).abs() < f64::EPSILON);
10200        assert!(
10201            (cfg.confidence_decay_half_life_for("team/bad")
10202                - crate::confidence::DEFAULT_HALF_LIFE_DAYS)
10203                .abs()
10204                < f64::EPSILON,
10205            "n15: non-positive override falls through to the default"
10206        );
10207        assert!(
10208            (cfg.confidence_decay_half_life_for("team/nan")
10209                - crate::confidence::DEFAULT_HALF_LIFE_DAYS)
10210                .abs()
10211                < f64::EPSILON,
10212            "n15: non-finite override falls through to the default"
10213        );
10214        // The boot-seed snapshot keeps only the admissible entries.
10215        let snap = cfg.confidence_decay_half_life_overrides();
10216        assert_eq!(snap.len(), 1, "only the finite positive entry survives");
10217        assert!((snap["team/eng"] - 14.0).abs() < f64::EPSILON);
10218    }
10219
10220    /// #1604 — rerank sequence-cap ladder: env >
10221    /// `[reranker].max_seq_tokens` > compiled default, with zero /
10222    /// unparseable / above-model-ceiling values falling through.
10223    #[test]
10224    fn resolve_reranker_1604_max_seq_ladder() {
10225        let _g = env_var_lock();
10226        unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10227
10228        // Compiled default when nothing is configured.
10229        let cfg = AppConfig::default();
10230        assert_eq!(
10231            cfg.resolve_reranker().max_seq_tokens,
10232            crate::reranker::RERANK_MAX_SEQ_DEFAULT
10233        );
10234
10235        // Config layer wins over the compiled default.
10236        let mut cfg = AppConfig::default();
10237        cfg.reranker = Some(RerankerSection {
10238            max_seq_tokens: Some(128),
10239            ..RerankerSection::default()
10240        });
10241        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10242
10243        // Env wins over config.
10244        unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "192") };
10245        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 192);
10246
10247        // Garbage env falls through to config.
10248        unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "not-a-number") };
10249        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10250
10251        // Zero env falls through to config.
10252        unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "0") };
10253        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10254
10255        // Above the model ceiling falls through to config.
10256        unsafe {
10257            std::env::set_var(
10258                ENV_RERANK_MAX_SEQ,
10259                (crate::reranker::CROSS_ENCODER_MAX_SEQ + 1).to_string(),
10260            );
10261        }
10262        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10263
10264        // Above-ceiling CONFIG value falls through to the compiled
10265        // default (no admissible layer remains).
10266        unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10267        let mut cfg = AppConfig::default();
10268        cfg.reranker = Some(RerankerSection {
10269            max_seq_tokens: Some(crate::reranker::CROSS_ENCODER_MAX_SEQ + 1),
10270            ..RerankerSection::default()
10271        });
10272        assert_eq!(
10273            cfg.resolve_reranker().max_seq_tokens,
10274            crate::reranker::RERANK_MAX_SEQ_DEFAULT
10275        );
10276
10277        unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10278    }
10279
10280    #[test]
10281    fn resolved_llm_1146_debug_redacts_api_key() {
10282        let resolved = ResolvedLlm {
10283            backend: "xai".into(),
10284            model: "grok-4.3".into(),
10285            base_url: "https://api.x.ai/v1".into(),
10286            api_key: Some("SUPER-SECRET-DONT-LEAK".into()),
10287            api_key_source: KeySource::ProcessEnv,
10288            source: ConfigSource::Env,
10289        };
10290        let dbg = format!("{resolved:?}");
10291        assert!(
10292            !dbg.contains("SUPER-SECRET-DONT-LEAK"),
10293            "Debug impl must redact the api_key: {dbg}"
10294        );
10295        assert!(
10296            dbg.contains("<redacted>"),
10297            "Debug impl must show <redacted> placeholder: {dbg}"
10298        );
10299    }
10300
10301    /// #1454 (SEC, LOW) — a `{:?}` of an `AppConfig` carrying the HTTP
10302    /// `api_key` MUST NOT echo the secret. `skip_serializing` only
10303    /// guarded the serde JSON path; the derived `Debug` leaked it. The
10304    /// manual `Debug` impl redacts the field while preserving the rest.
10305    #[test]
10306    fn app_config_1454_debug_redacts_api_key() {
10307        let cfg = AppConfig {
10308            tier: Some("autonomous".into()),
10309            api_key: Some("HTTP-BEARER-SUPER-SECRET".into()),
10310            ..AppConfig::default()
10311        };
10312        let dbg = format!("{cfg:?}");
10313        assert!(
10314            !dbg.contains("HTTP-BEARER-SUPER-SECRET"),
10315            "AppConfig Debug must redact api_key: {dbg}"
10316        );
10317        assert!(
10318            dbg.contains("<redacted>"),
10319            "AppConfig Debug must show <redacted> placeholder: {dbg}"
10320        );
10321        // Non-secret fields still render so the impl stays useful.
10322        assert!(
10323            dbg.contains("autonomous"),
10324            "AppConfig Debug must still render non-secret fields: {dbg}"
10325        );
10326    }
10327
10328    /// #1454 (SEC, LOW) — a `{:?}` of an `LlmSection` carrying an
10329    /// inline (parse-time-rejected, but still constructable in-memory)
10330    /// `api_key` MUST redact it; the env-var-name / file-path reference
10331    /// fields stay verbatim because they are not secrets.
10332    #[test]
10333    fn llm_section_1454_debug_redacts_api_key() {
10334        let section = LlmSection {
10335            backend: Some("xai".into()),
10336            api_key: Some("LLM-INLINE-SUPER-SECRET".into()),
10337            api_key_env: Some("XAI_API_KEY".into()),
10338            ..LlmSection::default()
10339        };
10340        let dbg = format!("{section:?}");
10341        assert!(
10342            !dbg.contains("LLM-INLINE-SUPER-SECRET"),
10343            "LlmSection Debug must redact api_key: {dbg}"
10344        );
10345        assert!(
10346            dbg.contains("<redacted>"),
10347            "LlmSection Debug must show <redacted> placeholder: {dbg}"
10348        );
10349        assert!(
10350            dbg.contains("XAI_API_KEY"),
10351            "api_key_env (a name, not a secret) must stay verbatim: {dbg}"
10352        );
10353    }
10354}