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            curator_mode: IMPLEMENTED.to_string(),
1256        }
1257    }
1258}
1259
1260fn default_capability_reflection() -> CapabilityReflection {
1261    CapabilityReflection::current()
1262}
1263
1264/// v0.7.0 L3-5 — Agent-Skills capability surface.
1265///
1266/// Every field MUST map to a real implementation:
1267///
1268/// - `implemented`: 7 MCP tools wired in
1269///   [`crate::mcp::registry`] + handlers in
1270///   [`crate::mcp::tools::skill_*`].
1271/// - `standard`: the parser in [`crate::parsing::skill_md`] validates
1272///   names + frontmatter against the agentskills.io §3.1/§3.2 spec.
1273/// - `tools`: list mirrors the registered handler names verbatim;
1274///   regression test [`SKILL_TOOL_NAMES`] verifies the slice matches
1275///   the live MCP dispatcher.
1276/// - `round_trip`: `memory_skill_register` → `memory_skill_export` →
1277///   re-register produces the IDENTICAL SHA-256 digest (see
1278///   `tests/skill_test.rs`, the round-trip pin).
1279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1280pub struct CapabilitySkills {
1281    /// `true` whenever the skill registration + lookup substrate is
1282    /// wired. False is reserved for a build that compiled the family out.
1283    pub implemented: bool,
1284    /// External spec the parser targets. `"agentskills.io"` is the
1285    /// canonical name documented in the L1-5 spec.
1286    pub standard: String,
1287    /// Canonical list of registered skill tools. Order matches the MCP
1288    /// dispatch order so an LLM that pins the order doesn't drift.
1289    pub tools: Vec<String>,
1290    /// `"verified"` when register → export → re-register is exercised in
1291    /// the test suite and the digests match.
1292    pub round_trip: String,
1293}
1294
1295/// Canonical skill tool names as registered in
1296/// [`crate::mcp::registry`]. Pinned here (not derived from the registry)
1297/// so the capability surface remains a stable, declarative contract;
1298/// the regression test
1299/// `cap_v3_l3_5_skill_tools_match_registered_mcp_dispatch` ensures the
1300/// two stay in sync.
1301// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — each
1302// entry routes through the canonical `tool_names` const so this
1303// capability surface cannot drift from the dispatch table in name
1304// spelling. The `cap_v3_l3_5_skill_tools_match_registered_mcp_dispatch`
1305// regression test continues to enforce membership equality between
1306// this slice and the registered set.
1307pub const SKILL_TOOL_NAMES: &[&str] = &[
1308    crate::mcp::registry::tool_names::MEMORY_SKILL_REGISTER,
1309    crate::mcp::registry::tool_names::MEMORY_SKILL_LIST,
1310    crate::mcp::registry::tool_names::MEMORY_SKILL_GET,
1311    crate::mcp::registry::tool_names::MEMORY_SKILL_RESOURCE,
1312    crate::mcp::registry::tool_names::MEMORY_SKILL_EXPORT,
1313    crate::mcp::registry::tool_names::MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
1314    crate::mcp::registry::tool_names::MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
1315];
1316
1317impl CapabilitySkills {
1318    /// Build the L3-5 skills capability from real, code-anchored values.
1319    #[must_use]
1320    pub fn current() -> Self {
1321        Self {
1322            implemented: true,
1323            standard: "agentskills.io".to_string(),
1324            tools: SKILL_TOOL_NAMES.iter().map(|s| (*s).to_string()).collect(),
1325            round_trip: "verified".to_string(),
1326        }
1327    }
1328}
1329
1330fn default_capability_skills() -> CapabilitySkills {
1331    CapabilitySkills::current()
1332}
1333
1334/// Capability-matrix value string — a surface is reported as
1335/// `"implemented"` once its engine/hook/wrapper code is live. One named
1336/// const so the 18 matrix cells share a single spelling (pm-v3.1
1337/// hardcoded-literal gate, #1558 wave 4).
1338const IMPLEMENTED: &str = "implemented";
1339
1340/// v0.7.0 L3-5 — forensic-evidence capability surface.
1341///
1342/// Each label names a CLI / function pair that **exists** in this binary:
1343///
1344/// - `verify_reflection_chain`: `ai-memory verify-reflection-chain` —
1345///   driver lives in [`crate::cli::verify`].
1346/// - `export_forensic_bundle`: `ai-memory export-forensic-bundle` —
1347///   builder lives in [`crate::forensic::bundle::build`].
1348/// - `verify_forensic_bundle`: `ai-memory verify-forensic-bundle` —
1349///   verifier lives in [`crate::forensic::bundle::verify`].
1350///
1351/// All three are `"implemented"` strings (not bools) so future
1352/// increments can promote a value to `"attested"` or `"scheduled"`
1353/// without a wire-shape break.
1354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1355pub struct CapabilityForensic {
1356    pub verify_reflection_chain: String,
1357    pub export_forensic_bundle: String,
1358    pub verify_forensic_bundle: String,
1359}
1360
1361impl CapabilityForensic {
1362    /// Build the L3-5 forensic capability — all three driver paths are
1363    /// wired in this build.
1364    #[must_use]
1365    pub fn current() -> Self {
1366        Self {
1367            verify_reflection_chain: IMPLEMENTED.to_string(),
1368            export_forensic_bundle: IMPLEMENTED.to_string(),
1369            verify_forensic_bundle: IMPLEMENTED.to_string(),
1370        }
1371    }
1372}
1373
1374fn default_capability_forensic() -> CapabilityForensic {
1375    CapabilityForensic::current()
1376}
1377
1378/// v0.7.0 L3-5 — substrate-rules governance capability surface.
1379///
1380/// Surfaces the L1-6 activation posture honestly:
1381///
1382/// - `rules_engine`: `"operator_signed"` because the L1-6 loader
1383///   refuses to honour any `enabled = 1` rule that is not
1384///   `attest_level = 'operator_signed'` and whose signature does not
1385///   verify against the active operator pubkey
1386///   ([`crate::governance::rules_store`] L1-6 audit).
1387/// - `enforced_actions`: the actual variant set in
1388///   [`crate::governance::agent_action::AgentAction`] minus the
1389///   `Custom` extension point (extension points are not
1390///   substrate-enforced). v0.7.0 ships **four** action kinds at the
1391///   harness-mediated PreToolUse boundary.
1392/// - `bypass_impossibility_tests`: count of `#[test]` functions in
1393///   [`tests/governance_l16_activation.rs`] verifying the
1394///   bypass-impossibility properties (signature-required, tampered-sig
1395///   rejected, direct-enabled-flip rejected, keygen 0600, idempotent
1396///   sign-seed, rotated-key invalidates). The number reflects the test
1397///   file as of v0.7.0 — bumping it requires an audit pass and a
1398///   matching test addition.
1399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1400pub struct CapabilityGovernance {
1401    pub rules_engine: String,
1402    pub enforced_actions: Vec<String>,
1403    pub bypass_impossibility_tests: u32,
1404    /// v0.7.0 SEC-2 (Cluster D, issue #767) — `true` when an operator
1405    /// pubkey is resolved (env var or `~/.config/ai-memory/operator.key.pub`)
1406    /// AND therefore the L1-6 loader is in attest-enforcing mode (every
1407    /// `enabled = 1` row MUST be operator-signed to fire). `false` when
1408    /// the substrate is in pre-L1-6 / fail-OPEN compat mode — every
1409    /// enabled rule passes through without signature verification.
1410    ///
1411    /// Clients that need to display the deployment's enforcement
1412    /// posture (operator dashboard, MCP-inspect tool, capabilities
1413    /// summary) can render this flag verbatim. Defaults to `false`
1414    /// for envelopes serialised before SEC-2 to preserve wire
1415    /// compatibility.
1416    #[serde(default)]
1417    pub l1_6_attest: bool,
1418}
1419
1420/// v0.7.0 L1-6 — the canonical agent-external action kinds the
1421/// substrate gates via the operator-signed rules engine. Matches the
1422/// variant set in [`crate::governance::agent_action::AgentAction`]
1423/// (minus the open-ended `Custom` extension point).
1424///
1425/// #1605 — the values are the snake_case **wire tags** from
1426/// [`crate::governance::agent_action::action_kinds`] (the #1558 SSOT
1427/// the `memory_check_agent_action` MCP parser, the CLI `rules test`
1428/// parser, and the `governance_rules.kind` column all share), NOT the
1429/// Rust variant names. The pre-#1605 list advertised `"Bash"` /
1430/// `"FilesystemWrite"` / … — tokens the kind parser refuses — so a
1431/// caller following capabilities verbatim got `unknown kind`.
1432///
1433/// MemoryWrite is intentionally NOT in this list — substrate-internal
1434/// memory writes are gated by the K9 `Op` pipeline
1435/// ([`crate::governance::Op`]) which is a separate, substrate-
1436/// authoritative surface. The two engines have different enforcement
1437/// semantics; honest reporting keeps them on separate fields rather
1438/// than conflating them under one label. The L3-5 audit comment in
1439/// `tests/capabilities_v3_l3_5.rs` documents the carry-forward.
1440pub const ENFORCED_AGENT_ACTIONS: &[&str] = &[
1441    crate::governance::agent_action::action_kinds::BASH,
1442    crate::governance::agent_action::action_kinds::FILESYSTEM_WRITE,
1443    crate::governance::agent_action::action_kinds::NETWORK_REQUEST,
1444    crate::governance::agent_action::action_kinds::PROCESS_SPAWN,
1445];
1446
1447/// v0.7.0 L1-6 — number of bypass-impossibility tests pinning the
1448/// rules-engine activation posture. Tracks the `#[test]` count in
1449/// `tests/governance_l16_activation.rs`. Bumping this requires both an
1450/// audit and a matching test landing in that file.
1451pub const GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS: u32 = 6;
1452
1453impl CapabilityGovernance {
1454    /// Build the L3-5 governance capability from the live constants.
1455    #[must_use]
1456    pub fn current() -> Self {
1457        Self {
1458            rules_engine: "operator_signed".to_string(),
1459            enforced_actions: ENFORCED_AGENT_ACTIONS
1460                .iter()
1461                .map(|s| (*s).to_string())
1462                .collect(),
1463            bypass_impossibility_tests: GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS,
1464            // SEC-2 — reflect the live pubkey-resolution state at
1465            // envelope construction time. The pubkey lookup is
1466            // filesystem + env; cheap relative to the rest of the
1467            // capabilities-v3 build path.
1468            l1_6_attest: crate::governance::rules_store::l1_6_attest_active(),
1469        }
1470    }
1471}
1472
1473fn default_capability_governance() -> CapabilityGovernance {
1474    CapabilityGovernance::current()
1475}
1476
1477/// v0.7.0 WT-1-G — atomisation capability surface.
1478///
1479/// WT-1 ships substrate-native decomposition of long memories into
1480/// atomic propositions. The parent memory is archived (`archived_at`
1481/// stamped, `atomised_into = N`) and `N` first-class atomic children
1482/// land with `atom_of` back-pointers and a signed `derives_from`
1483/// `MemoryLink`. Each sub-field below names a real operator-facing
1484/// surface in this binary; the round-trip is honest — the values are
1485/// `"implemented"` only when the engine, hook, and wrapper code are
1486/// all wired.
1487///
1488/// Field → implementation anchor map:
1489///
1490/// - `tool`: MCP `memory_atomise` (Family::Power). Defined in
1491///   [`crate::mcp::tools::atomise`] + registered in
1492///   [`crate::mcp::registry`]. WT-1-C landed it.
1493/// - `cli`: `ai-memory atomise <memory_id>` subcommand. Wrapper lives
1494///   in [`crate::cli::commands::atomise`]. WT-1-F landed it.
1495/// - `auto`: namespace-policy-gated `auto_atomise` pre_store hook.
1496///   The hook in [`crate::hooks::pre_store::auto_atomise`] is
1497///   non-blocking (detached worker thread) and fires only when the
1498///   namespace standard's `metadata.governance.auto_atomise = true`.
1499///   WT-1-D landed it.
1500/// - `recall_preference`: recall surfaces atoms in place of an
1501///   archived parent via the SQL guard
1502///   `AND NOT (archived_at IS NOT NULL AND atomised_into > 0)`.
1503///   WT-1-E landed it.
1504/// - `forensic`: forensic bundle export includes the parent → atoms
1505///   chain envelope so a downstream auditor reconstructs the
1506///   decomposition offline. WT-1-E landed it.
1507/// - `curator`: production `LlmCurator` uses the Gemma 4 prompt
1508///   with `tiktoken-rs::cl100k_base` token-budget validation and
1509///   the audit-honest STOP discipline (no retry after a parse-OK
1510///   verdict). WT-1-B landed it.
1511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1512pub struct CapabilityAtomisation {
1513    /// MCP `memory_atomise` tool — `"implemented"` once the tool is
1514    /// registered and the [`crate::mcp::tools::atomise`] handler is
1515    /// wired against [`crate::atomisation::Atomiser`].
1516    pub tool: String,
1517    /// `ai-memory atomise` CLI subcommand — `"implemented"` once the
1518    /// wrapper in [`crate::cli::commands::atomise`] is dispatched
1519    /// from `daemon_runtime::Command::Atomise`.
1520    pub cli: String,
1521    /// Namespace-policy-gated auto-atomisation pre_store hook —
1522    /// `"implemented"` when [`crate::hooks::pre_store::auto_atomise`]
1523    /// is compiled and the store handlers call
1524    /// `maybe_enqueue_auto_atomise` after a successful insert.
1525    pub auto: String,
1526    /// Recall-time atom preference — `"implemented"` when the recall
1527    /// SQL carries the
1528    /// `AND NOT (archived_at IS NOT NULL AND atomised_into > 0)`
1529    /// guard so atomised parents stop surfacing in their atoms'
1530    /// place. WT-1-E.
1531    pub recall_preference: String,
1532    /// Forensic chain envelope — `"implemented"` when the forensic
1533    /// bundle exporter ([`crate::forensic::bundle::build`]) walks
1534    /// `atom_of` back-pointers to include the parent → atoms chain
1535    /// in the bundle. WT-1-E.
1536    pub forensic: String,
1537    /// LLM curator — `"implemented"` once
1538    /// [`crate::atomisation::curator::LlmCurator`] is the production
1539    /// `Curator` impl driving the atomisation engine (Gemma 4 prompt,
1540    /// tiktoken-rs cl100k token-budget validation, audit-honest STOP).
1541    /// WT-1-B.
1542    pub curator: String,
1543    /// Memory-link relation that anchors the atom → parent edge.
1544    /// Always `"derives_from"`, matching
1545    /// [`crate::models::MemoryLinkRelation::DerivesFrom`]. Distinct
1546    /// from `related_to` / `supersedes` / `contradicts` — the
1547    /// atomisation engine writes this edge specifically, and
1548    /// downstream consumers can filter on the relation to walk
1549    /// decomposition lineage without reflection-chain noise.
1550    pub link_relation: String,
1551}
1552
1553impl CapabilityAtomisation {
1554    /// Build the WT-1-G atomisation capability surface from real,
1555    /// code-anchored values. Every `"implemented"` here is a claim
1556    /// pinned by [`tests/capabilities_v3_l3_5.rs`] and walked back to
1557    /// a registered MCP tool / CLI verb / hook module / SQL guard.
1558    #[must_use]
1559    pub fn current() -> Self {
1560        Self {
1561            tool: IMPLEMENTED.to_string(),
1562            cli: IMPLEMENTED.to_string(),
1563            auto: IMPLEMENTED.to_string(),
1564            recall_preference: IMPLEMENTED.to_string(),
1565            forensic: IMPLEMENTED.to_string(),
1566            curator: IMPLEMENTED.to_string(),
1567            link_relation: "derives_from".to_string(),
1568        }
1569    }
1570}
1571
1572fn default_capability_atomisation() -> CapabilityAtomisation {
1573    CapabilityAtomisation::current()
1574}
1575
1576// ---------------------------------------------------------------------------
1577// v0.7.x Form 6 — MemoryKind Batman-vocabulary capability surface (#759)
1578// ---------------------------------------------------------------------------
1579
1580/// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1581/// capability surface. Names the recall-filter / auto-classify
1582/// surfaces shipped under Form 6.
1583///
1584/// Field → implementation anchor map:
1585///
1586/// - `vocabulary`: the complete enumerated vocabulary the substrate
1587///   accepts on the `memory_kind` column. Always
1588///   `["observation", "reflection", "persona", "concept", "entity",
1589///   "claim", "relation", "event", "conversation", "decision"]` in
1590///   v0.7.x — anchored at compile time by
1591///   [`crate::models::MemoryKind::all`].
1592/// - `recall_filter`: MCP `memory_recall` and HTTP recall accept a
1593///   `kinds` parameter (CSV string or JSON array). `"implemented"`
1594///   once the param is plumbed into [`crate::mcp::tools::recall`]
1595///   and [`crate::handlers::http::recall_response`].
1596/// - `cli_filter`: `ai-memory recall --kind concept,entity` CLI
1597///   flag. `"implemented"` once the flag is wired in
1598///   [`crate::cli::recall::RecallArgs`].
1599/// - `auto_classify`: the namespace-policy-gated
1600///   `pre_store::auto_classify_kind` hook. `"implemented"` once
1601///   the hook module is compiled and `memory_store` calls
1602///   [`crate::hooks::pre_store::maybe_auto_classify`] after policy
1603///   resolution.
1604/// - `auto_classify_modes`: enumerated policy modes the operator
1605///   may set. Always `["off", "regex_only", "regex_then_llm"]` —
1606///   anchored against [`crate::models::MemoryKindAutoClassify`].
1607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1608pub struct CapabilityMemoryKindVocab {
1609    /// Complete enumerated vocabulary the substrate accepts on the
1610    /// `memory_kind` column. Compile-anchored.
1611    pub vocabulary: Vec<String>,
1612    /// MCP `memory_recall` + HTTP recall `kinds` param wiring.
1613    pub recall_filter: String,
1614    /// CLI `--kind` flag wiring.
1615    pub cli_filter: String,
1616    /// Namespace-policy-gated auto-classify pre_store hook wiring.
1617    pub auto_classify: String,
1618    /// Enumerated auto-classify policy modes (`off` / `regex_only` /
1619    /// `regex_then_llm`). Compile-anchored.
1620    pub auto_classify_modes: Vec<String>,
1621}
1622
1623impl CapabilityMemoryKindVocab {
1624    /// Build the Form 6 memory-kind-vocab capability surface from
1625    /// real, code-anchored values. Every `"implemented"` here is a
1626    /// claim pinned by [`tests/form_6_memorykind_vocab.rs`].
1627    #[must_use]
1628    pub fn current() -> Self {
1629        Self {
1630            vocabulary: crate::models::MemoryKind::all()
1631                .iter()
1632                .map(|k| k.as_str().to_string())
1633                .collect(),
1634            recall_filter: IMPLEMENTED.to_string(),
1635            cli_filter: IMPLEMENTED.to_string(),
1636            auto_classify: IMPLEMENTED.to_string(),
1637            auto_classify_modes: vec![
1638                "off".to_string(),
1639                "regex_only".to_string(),
1640                "regex_then_llm".to_string(),
1641            ],
1642        }
1643    }
1644}
1645
1646fn default_capability_memory_kind_vocab() -> CapabilityMemoryKindVocab {
1647    CapabilityMemoryKindVocab::current()
1648}
1649
1650// ---------------------------------------------------------------------------
1651// v0.7.0 Form 5 (issue #758) — auto-confidence + shadow-mode +
1652// calibration tooling capability surface.
1653// ---------------------------------------------------------------------------
1654
1655/// v0.7.0 Form 5 — operator-facing confidence-calibration capability
1656/// surface. Names every Form-5 substrate the binary actually ships:
1657///
1658/// - `auto_derive`: the [`crate::confidence::derive`] engine
1659///   (deterministic auto-confidence formula). Opt-in via
1660///   `AI_MEMORY_AUTO_CONFIDENCE=1` — the field reports `"implemented"`
1661///   because the engine compiles in unconditionally; the env-var gate
1662///   is the operator control plane.
1663/// - `shadow_mode`: the [`crate::confidence::shadow`] pipeline backed
1664///   by the `confidence_shadow_observations` table (schema v39 sqlite /
1665///   v38 postgres). Opt-in via `AI_MEMORY_CONFIDENCE_SHADOW=1`.
1666/// - `freshness_decay`: the [`crate::confidence::decay::decayed`]
1667///   exponential decay model. Opt-in via `AI_MEMORY_CONFIDENCE_DECAY=1`
1668///   or per-namespace `confidence_decay_half_life_days` policy.
1669/// - `calibration_cli`: the `ai-memory calibrate confidence
1670///   --from-shadow` driver verb that scans the observation table and
1671///   emits per-(namespace, source) baselines.
1672/// - `calibration_tool`: the `memory_calibrate_confidence` MCP tool
1673///   (Family::Power) — operator-callable equivalent of the CLI driver.
1674/// - `signals_schema`: the wire-shape discriminator for the JSON
1675///   envelope stored on `memories.confidence_signals`. Always
1676///   `"v1"` in v0.7.0 — bumped when the [`crate::models::ConfidenceSignals`]
1677///   struct gains a new field.
1678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1679pub struct CapabilityConfidenceCalibration {
1680    /// `"implemented"` once [`crate::confidence::derive`] is wired into
1681    /// the substrate (it compiles in regardless of feature flag).
1682    pub auto_derive: String,
1683    /// `"implemented"` once [`crate::confidence::shadow`] is wired
1684    /// (Form 5).
1685    pub shadow_mode: String,
1686    /// `"implemented"` once [`crate::confidence::decay`] is wired
1687    /// (Form 5).
1688    pub freshness_decay: String,
1689    /// `"implemented"` once the `ai-memory calibrate confidence` CLI
1690    /// driver registers under [`crate::cli`].
1691    pub calibration_cli: String,
1692    /// `"implemented"` once the `memory_calibrate_confidence` MCP
1693    /// tool registers under Family::Power.
1694    pub calibration_tool: String,
1695    /// Wire-shape discriminator for `memories.confidence_signals`.
1696    /// Always `"v1"` in v0.7.0.
1697    pub signals_schema: String,
1698    /// Default freshness-decay half-life (days). 30 in v0.7.0; tunable
1699    /// per namespace via the `confidence_decay_half_life_days` policy.
1700    pub default_half_life_days: f64,
1701    /// v0.7.0 Gap 4 (#887) — derived-tier thresholds. MCP callers
1702    /// reading this surface know how the substrate buckets the
1703    /// `confidence` real into `confirmed` / `likely` / `ambiguous`
1704    /// without re-deriving the breakpoints. Stable; bumping is a
1705    /// wire-level break (see [`crate::models::ConfidenceTier`]).
1706    /// `#[serde(default)]` keeps pre-Gap-4 capability consumers
1707    /// reading newer payloads from breaking.
1708    #[serde(default)]
1709    pub tier_thresholds: ConfidenceTierThresholds,
1710}
1711
1712impl CapabilityConfidenceCalibration {
1713    /// Build the Form 5 capability surface from real, code-anchored
1714    /// values. Every `"implemented"` here is a claim pinned by
1715    /// `tests/form_5_confidence_calibration.rs` and walked back to a
1716    /// registered MCP tool / CLI verb / module file.
1717    #[must_use]
1718    pub fn current() -> Self {
1719        Self {
1720            auto_derive: IMPLEMENTED.to_string(),
1721            shadow_mode: IMPLEMENTED.to_string(),
1722            freshness_decay: IMPLEMENTED.to_string(),
1723            calibration_cli: IMPLEMENTED.to_string(),
1724            calibration_tool: IMPLEMENTED.to_string(),
1725            signals_schema: "v1".to_string(),
1726            default_half_life_days: crate::confidence::DEFAULT_HALF_LIFE_DAYS,
1727            tier_thresholds: ConfidenceTierThresholds::default(),
1728        }
1729    }
1730}
1731
1732fn default_capability_confidence_calibration() -> CapabilityConfidenceCalibration {
1733    CapabilityConfidenceCalibration::current()
1734}
1735
1736// ---------------------------------------------------------------------------
1737// Capabilities v1 — legacy shape retained for backward compat
1738// ---------------------------------------------------------------------------
1739
1740/// Legacy (v1) capabilities shape — the structure shipped before the
1741/// v0.6.3.1 honesty patch. Returned only when a client opts in via
1742/// `Accept-Capabilities: v1` (HTTP) or the MCP `accept` argument set
1743/// to `"v1"`. Default response is v2.
1744///
1745/// The v1 schema is frozen — do not extend it. New fields go into v2
1746/// (see [`Capabilities`]).
1747#[derive(Debug, Clone, Serialize, Deserialize)]
1748pub struct CapabilitiesV1 {
1749    pub tier: String,
1750    pub version: String,
1751    pub features: CapabilityFeaturesV1,
1752    pub models: CapabilityModels,
1753}
1754
1755/// Legacy v1 feature-flag block. Notably, `memory_reflection` is a
1756/// `bool` here (it became a `PlannedFeature` object in v2).
1757#[allow(clippy::struct_excessive_bools)]
1758#[derive(Debug, Clone, Serialize, Deserialize)]
1759pub struct CapabilityFeaturesV1 {
1760    pub keyword_search: bool,
1761    pub semantic_search: bool,
1762    pub hybrid_recall: bool,
1763    pub query_expansion: bool,
1764    pub auto_consolidation: bool,
1765    pub auto_tagging: bool,
1766    pub contradiction_analysis: bool,
1767    pub cross_encoder_reranking: bool,
1768    pub memory_reflection: bool,
1769    #[serde(default)]
1770    pub embedder_loaded: bool,
1771}
1772
1773impl Capabilities {
1774    /// Project the v2 report down to the legacy v1 shape. Used to
1775    /// honour `Accept-Capabilities: v1` from older clients.
1776    ///
1777    /// `memory_reflection` collapses from `{planned, enabled}` to a
1778    /// single bool (`enabled` value). All v2-only fields
1779    /// (`recall_mode_active`, `reranker_active`, `permissions`,
1780    /// `hooks`, `compaction`, `approval`, `transcripts`) are dropped.
1781    #[must_use]
1782    pub fn to_v1(&self) -> CapabilitiesV1 {
1783        CapabilitiesV1 {
1784            tier: self.tier.clone(),
1785            version: self.version.clone(),
1786            features: CapabilityFeaturesV1 {
1787                keyword_search: self.features.keyword_search,
1788                semantic_search: self.features.semantic_search,
1789                hybrid_recall: self.features.hybrid_recall,
1790                query_expansion: self.features.query_expansion,
1791                auto_consolidation: self.features.auto_consolidation,
1792                auto_tagging: self.features.auto_tagging,
1793                contradiction_analysis: self.features.contradiction_analysis,
1794                cross_encoder_reranking: self.features.cross_encoder_reranking,
1795                memory_reflection: self.features.memory_reflection.enabled,
1796                embedder_loaded: self.features.embedder_loaded,
1797            },
1798            models: self.models.clone(),
1799        }
1800    }
1801
1802    /// v0.7.0 (A1+A2+A3+A4): project the report into the v3 shape.
1803    ///
1804    /// v3 = v2 +
1805    ///   - top-level `summary` (A1) — terse description of operational
1806    ///     access plus the three named recovery paths.
1807    ///   - top-level `to_describe_to_user` (A2) — plain-English
1808    ///     end-user-facing sentence the LLM should repeat verbatim
1809    ///     when asked "what tools do you have?". No MCP jargon.
1810    ///   - top-level `tools` (A3) — per-tool array carrying name,
1811    ///     family, `loaded`, and `callable_now`. `callable_now`
1812    ///     combines profile-side loaded-state with the
1813    ///     `[mcp.allowlist]` agent-can-call decision so an LLM that
1814    ///     keeps a manifest cache doesn't need to ask twice to know
1815    ///     whether a tool will resolve.
1816    ///   - top-level `agent_permitted_families` (A4, optional) — when
1817    ///     the `[mcp.allowlist]` is enabled AND an `agent_id` is
1818    ///     provided, lists the family names the requesting agent is
1819    ///     allowed to access (collapses every callable_now=true entry's
1820    ///     family to a unique list). When the allowlist is disabled or
1821    ///     no agent_id is provided, the field is omitted from the wire
1822    ///     (so v2-shaped consumers see no churn from A4 alone).
1823    ///
1824    /// All four are computed by the caller from the live `Profile` +
1825    /// `McpConfig` + `agent_id` state because the [`Capabilities`]
1826    /// struct itself doesn't know which families the MCP server
1827    /// actually advertised or which agent is asking.
1828    ///
1829    /// A5 bumps the default wire shape to v3. v2 stays supported
1830    /// indefinitely.
1831    #[must_use]
1832    pub fn to_v3(
1833        &self,
1834        summary: String,
1835        to_describe_to_user: String,
1836        tools: Vec<ToolEntry>,
1837        agent_permitted_families: Option<Vec<String>>,
1838        your_harness_supports_deferred_registration: Option<bool>,
1839    ) -> CapabilitiesV3 {
1840        CapabilitiesV3 {
1841            schema_version: "3".to_string(),
1842            summary,
1843            to_describe_to_user,
1844            tools,
1845            agent_permitted_families,
1846            your_harness_supports_deferred_registration,
1847            tier: self.tier.clone(),
1848            version: self.version.clone(),
1849            features: self.features.clone(),
1850            models: self.models.clone(),
1851            permissions: self.permissions.clone(),
1852            hooks: self.hooks.clone(),
1853            compaction: self.compaction.clone(),
1854            approval: self.approval.clone(),
1855            transcripts: self.transcripts.clone(),
1856            hnsw: self.hnsw.clone(),
1857            // v0.7 J1 — propagate the resolved KG backend tag verbatim.
1858            // None when no SAL adapter is wired (every pre-J2 build);
1859            // `Some("age" | "cte")` once the SAL handle is threaded.
1860            kg_backend: self.kg_backend.clone(),
1861            // L1-1 — propagate the memory-kind set verbatim.
1862            memory_kinds: self.memory_kinds.clone(),
1863            // L3-5 — four new substrate-honesty blocks. Built from
1864            // compile-time anchors (the per-block `::current()`
1865            // constructor) so the wire shape reflects the actual
1866            // implementation surface, not a static template.
1867            reflection: CapabilityReflection::current(),
1868            skills: CapabilitySkills::current(),
1869            forensic: CapabilityForensic::current(),
1870            governance: CapabilityGovernance::current(),
1871            // v0.7.0 WT-1-G — operator-facing atomisation surface.
1872            // Anchored at compile time against the WT-1-{A..F} ships
1873            // (engine, curator, hook, recall guard, forensic bundle,
1874            // MCP tool, CLI subcommand).
1875            atomisation: CapabilityAtomisation::current(),
1876            // v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1877            // vocabulary surface. Anchored at compile time against the
1878            // [`crate::models::MemoryKind`] enum + the recall-filter /
1879            // CLI / auto-classify wiring shipped under Form 6.
1880            memory_kind_vocab: CapabilityMemoryKindVocab::current(),
1881            // v0.7.0 Form 5 (issue #758) — confidence-calibration
1882            // surface. Anchored at compile time against the
1883            // `crate::confidence` module (derive, shadow, decay,
1884            // calibrate), the `ai-memory calibrate confidence` CLI
1885            // subcommand, and the `memory_calibrate_confidence` MCP
1886            // tool.
1887            confidence_calibration: CapabilityConfidenceCalibration::current(),
1888            // v0.7.0 #973 Item C — do-calculus / Ortega-de-Freitas
1889            // narrative surface. Helper does the source-tree honesty
1890            // check at the comment site; see the helper's docstring.
1891            provenance_substrate_layer: default_capability_provenance_substrate_layer(),
1892        }
1893    }
1894}
1895
1896/// v0.7.0 A3 — per-tool entry in the capabilities-v3 `tools` array.
1897///
1898/// `loaded` mirrors `Profile::loads(name)` — true when the active
1899/// profile would advertise this tool in `tools/list`.
1900///
1901/// `callable_now` is the AND of `loaded` with the
1902/// `[mcp.allowlist]` per-agent gate. When the allowlist is disabled
1903/// (no `[mcp.allowlist]` table or empty table), `callable_now ==
1904/// loaded`. When the allowlist is active and the requesting agent
1905/// has no entry granting the tool's family, `callable_now == false`
1906/// even though `loaded == true`.
1907///
1908/// LLMs that cache the v3 manifest can use this to skip a doomed
1909/// JSON-RPC call rather than discover -32601 the hard way.
1910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1911pub struct ToolEntry {
1912    /// Fully-qualified MCP tool name (e.g., `memory_store`).
1913    pub name: String,
1914    /// Family the tool belongs to. Always one of the eight canonical
1915    /// family names (`core`, `lifecycle`, `graph`, etc.) or
1916    /// `"always_on"` for the `memory_capabilities` bootstrap which
1917    /// doesn't sit in any single family from a registration standpoint.
1918    pub family: String,
1919    /// Whether the active profile's family set includes this tool's
1920    /// family (i.e., it appears in `tools/list`).
1921    pub loaded: bool,
1922    /// `loaded && agent_can_call(agent_id, family)`. When the
1923    /// `[mcp.allowlist]` is disabled, `callable_now == loaded`.
1924    pub callable_now: bool,
1925    /// v0.7.0 issue #803 — 0-2 worked examples for the tool.
1926    /// `skip_serializing_if = "Vec::is_empty"` strips the field
1927    /// for any tool without curated examples.
1928    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1929    pub examples: Vec<ToolExample>,
1930}
1931
1932/// v0.7.0 issue #803 — single worked example for `tools[].examples`.
1933#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1934pub struct ToolExample {
1935    pub call: serde_json::Value,
1936    pub description: String,
1937}
1938
1939// ---------------------------------------------------------------------------
1940// Capabilities v3 — v0.7.0 attested-cortex schema (additive over v2)
1941// ---------------------------------------------------------------------------
1942
1943/// v0.7.0 capabilities schema (A1 increment). Additive over [`Capabilities`]
1944/// (v2): the top-level `summary` field carries a pre-computed,
1945/// plain-language description of the LLM's operational tool surface so
1946/// reasoning-class LLMs converge on accurate first-answer descriptions
1947/// without having to traverse `families[]` and count manually.
1948///
1949/// Wire selection: clients opt in via `accept="v3"` on the MCP
1950/// `memory_capabilities` call, or `Accept-Capabilities: v3` over HTTP
1951/// (HTTP wiring lands with A5). Default response remains v2 until A5
1952/// flips the default. v2 stays supported indefinitely.
1953///
1954/// Increment plan: A2 adds `to_describe_to_user`, A3 adds per-tool
1955/// `callable_now`, A4 adds `agent_permitted_families`. A5 bumps the
1956/// default wire shape and seals v3 as the recommended client target.
1957#[derive(Debug, Clone, Serialize, Deserialize)]
1958pub struct CapabilitiesV3 {
1959    /// Schema-version discriminator. Always `"3"` in v0.7.0.
1960    pub schema_version: String,
1961
1962    /// Pre-computed plain-language summary of operational access.
1963    /// Carries the loaded vs total tool counts under the active profile
1964    /// plus the three named recovery paths (`--profile`,
1965    /// `memory_load_family`, `memory_smart_load`). Computed at response
1966    /// time from the live profile state — never cached at build time
1967    /// because the count of advertised tools depends on the running
1968    /// server's `--profile` flag.
1969    pub summary: String,
1970
1971    /// v0.7.0 A2 — pre-computed end-user-facing sentence the LLM should
1972    /// repeat verbatim when an end-user asks "what tools do you have?".
1973    /// Distinct in tone from [`Self::summary`]: no MCP jargon, no
1974    /// `--profile` references, no JSON-RPC vocabulary. Reads as a
1975    /// normal sentence a human would say. See
1976    /// `docs/v0.7/canonical-phrasings.md` for the canonical
1977    /// substitution template + worked examples per profile.
1978    pub to_describe_to_user: String,
1979
1980    /// v0.7.0 A3 — per-tool array carrying name, family, `loaded`, and
1981    /// `callable_now`. `callable_now` combines profile-side
1982    /// loaded-state with the `[mcp.allowlist]` agent-can-call decision
1983    /// so an LLM that caches this manifest can skip a doomed JSON-RPC
1984    /// call rather than discovering -32601 the hard way. Order matches
1985    /// `tool_definitions()`'s registration walk so a sequential reader
1986    /// gets a stable presentation.
1987    pub tools: Vec<ToolEntry>,
1988
1989    /// v0.7.0 A4 — list of family names this agent is permitted to
1990    /// access via the `[mcp.allowlist]` gate. Present (with possibly
1991    /// an empty array) only when the allowlist is configured AND an
1992    /// `agent_id` was provided. Absent when the allowlist is disabled
1993    /// or no agent_id was provided — that absence is meaningful, not a
1994    /// drift, hence `Option<Vec<String>>` + `skip_serializing_if`.
1995    ///
1996    /// LLMs that keep a per-agent manifest cache can use this to
1997    /// short-circuit family-level decisions without iterating
1998    /// `tools[]` and counting unique families.
1999    #[serde(default, skip_serializing_if = "Option::is_none")]
2000    pub agent_permitted_families: Option<Vec<String>>,
2001
2002    /// v0.7.0 B4 — whether the active MCP harness exposes tools
2003    /// registered *after* the initial `tools/list` to the LLM. Computed
2004    /// at response time from the harness detected at the
2005    /// `initialize.clientInfo.name` handshake (see `crate::harness`).
2006    ///
2007    /// `Some(true)` only for Claude Code today (deferred registration
2008    /// via `ToolSearch`). `Some(false)` for every other named harness.
2009    /// `None` (omitted from the wire via `skip_serializing_if`) when
2010    /// no `clientInfo` was captured — typically HTTP callers, or an
2011    /// MCP client that issued `memory_capabilities` before
2012    /// `initialize` (malformed but defensively handled by absence).
2013    ///
2014    /// Track B's runtime loaders (B1 `memory_load_family`, B2
2015    /// `memory_smart_load`) key off this bit to shape their
2016    /// `to_invoke` text — on `false` harnesses they advise the LLM to
2017    /// ask the operator for a `--profile <family>` restart rather
2018    /// than expect the new tools to appear mid-session.
2019    #[serde(default, skip_serializing_if = "Option::is_none")]
2020    pub your_harness_supports_deferred_registration: Option<bool>,
2021
2022    pub tier: String,
2023    pub version: String,
2024    pub features: CapabilityFeatures,
2025    pub models: CapabilityModels,
2026    pub permissions: CapabilityPermissions,
2027    pub hooks: CapabilityHooks,
2028    pub compaction: CapabilityCompaction,
2029    pub approval: CapabilityApproval,
2030    pub transcripts: CapabilityTranscripts,
2031
2032    #[serde(default)]
2033    pub hnsw: CapabilityHnsw,
2034
2035    /// v0.7 J1 — knowledge-graph backend tag forwarded from the v2
2036    /// projection. `Some("age" | "cte")` once the SAL handle is
2037    /// threaded through `AppState`; `None` while no SAL adapter is
2038    /// wired. Skipped from the JSON wire when `None` so older clients
2039    /// that don't know the field round-trip cleanly.
2040    #[serde(default, skip_serializing_if = "Option::is_none")]
2041    pub kg_backend: Option<String>,
2042
2043    /// L1-1 (v0.7.0) — typed memory-kind set. Forwarded from the v2
2044    /// projection's `memory_kinds` field. Always
2045    /// `["observation", "reflection"]` for v0.7.0.
2046    ///
2047    /// **L3-5 honesty note.** The grand-slam spec called for a third
2048    /// `"goal"` kind here, but the [`crate::models::memory::MemoryKind`]
2049    /// enum in this binary only carries `Observation` and `Reflection`.
2050    /// Per the operator's "every reported field maps to real
2051    /// implementation" directive, the v3 surface reports exactly what
2052    /// the substrate enforces — the `goal` kind is deferred to the
2053    /// tracker (`a4f8d465`) for a v0.8.0 wave that lands the enum
2054    /// variant + migration + write-path coverage. Reporting it here
2055    /// today would be theatrical.
2056    #[serde(default = "default_memory_kinds")]
2057    pub memory_kinds: Vec<String>,
2058
2059    /// v0.7.0 L3-5 — recursive-learning capability surface. Every
2060    /// sub-field anchors a real implementation in this binary; see
2061    /// [`CapabilityReflection`] for the per-field audit anchors.
2062    #[serde(default = "default_capability_reflection")]
2063    pub reflection: CapabilityReflection,
2064
2065    /// v0.7.0 L3-5 — Agent-Skills capability surface. Lists the seven
2066    /// registered `memory_skill_*` MCP tools; the round-trip guarantee
2067    /// is pinned by `tests/skill_test.rs`. See [`CapabilitySkills`].
2068    #[serde(default = "default_capability_skills")]
2069    pub skills: CapabilitySkills,
2070
2071    /// v0.7.0 L3-5 — forensic-evidence CLI surface. Names the three
2072    /// driver verbs that this binary actually ships
2073    /// (`verify-reflection-chain`, `export-forensic-bundle`,
2074    /// `verify-forensic-bundle`). See [`CapabilityForensic`].
2075    #[serde(default = "default_capability_forensic")]
2076    pub forensic: CapabilityForensic,
2077
2078    /// v0.7.0 L3-5 — substrate-rules governance surface. Honestly
2079    /// labelled `"operator_signed"` because the L1-6 loader refuses
2080    /// to honour unsigned rules. See [`CapabilityGovernance`].
2081    #[serde(default = "default_capability_governance")]
2082    pub governance: CapabilityGovernance,
2083
2084    /// v0.7.0 WT-1-G — atomisation capability surface. Names the six
2085    /// operator-facing atomisation surfaces (`tool` / `cli` / `auto` /
2086    /// `recall_preference` / `forensic` / `curator`) plus the
2087    /// `derives_from` link relation that anchors atom → parent
2088    /// lineage. See [`CapabilityAtomisation`] for the per-field
2089    /// implementation anchor map.
2090    ///
2091    /// Additive over the L3-5 surface — pre-WT-1-G v3 payloads still
2092    /// deserialise cleanly (the `default_capability_atomisation`
2093    /// helper resolves to the current-implementation snapshot for any
2094    /// payload missing the field).
2095    #[serde(default = "default_capability_atomisation")]
2096    pub atomisation: CapabilityAtomisation,
2097
2098    /// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
2099    /// vocabulary capability surface. Names the recall-filter +
2100    /// auto-classify surfaces shipped under Form 6 and enumerates
2101    /// the substrate's full set of recognised `memory_kind` values.
2102    /// See [`CapabilityMemoryKindVocab`].
2103    ///
2104    /// Additive over the WT-1-G surface — pre-Form-6 v3 payloads
2105    /// deserialise cleanly via the
2106    /// `default_capability_memory_kind_vocab` helper.
2107    #[serde(default = "default_capability_memory_kind_vocab")]
2108    pub memory_kind_vocab: CapabilityMemoryKindVocab,
2109
2110    /// v0.7.0 Form 5 (issue #758) — confidence-calibration capability
2111    /// surface. Names the five operator-facing Form-5 substrates
2112    /// (`auto_derive` / `shadow_mode` / `freshness_decay` /
2113    /// `calibration_cli` / `calibration_tool`) plus the
2114    /// `signals_schema` wire-shape discriminator. See
2115    /// [`CapabilityConfidenceCalibration`] for the per-field anchor
2116    /// map.
2117    ///
2118    /// Additive over the WT-1-G surface — pre-Form-5 v3 payloads still
2119    /// deserialise cleanly because of the
2120    /// `default_capability_confidence_calibration` helper.
2121    #[serde(default = "default_capability_confidence_calibration")]
2122    pub confidence_calibration: CapabilityConfidenceCalibration,
2123
2124    /// v0.7.0 #973 Item C — narrative summary of the substrate's
2125    /// do-calculus posture.
2126    #[serde(default = "default_capability_provenance_substrate_layer")]
2127    pub provenance_substrate_layer: CapabilityProvenanceSubstrateLayer,
2128}
2129
2130/// v0.7.0 #973 Item C — substrate-layer provenance posture. Lets an
2131/// LLM agent self-describe ai-memory's do-calculus
2132/// intervention/observation distinction (Pearl 2009) per Ortega &
2133/// de Freitas (2026) framing. Honesty discipline: every
2134/// `enforcement_layers` entry must map to a shipped substrate
2135/// primitive in source.
2136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2137pub struct CapabilityProvenanceSubstrateLayer {
2138    #[serde(default)]
2139    pub posture: String,
2140    #[serde(default)]
2141    pub summary: String,
2142    #[serde(default)]
2143    pub enforcement_layers: Vec<String>,
2144    #[serde(default)]
2145    pub honest_limitations: Vec<String>,
2146    #[serde(default)]
2147    pub spec_references: SpecReferences,
2148}
2149
2150/// v0.7.0 #973 Item C — academic citations. Vendor-neutral.
2151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2152pub struct SpecReferences {
2153    #[serde(default)]
2154    pub do_calculus: String,
2155    #[serde(default)]
2156    pub interactional_agency: String,
2157}
2158
2159#[must_use]
2160pub fn default_capability_provenance_substrate_layer() -> CapabilityProvenanceSubstrateLayer {
2161    CapabilityProvenanceSubstrateLayer {
2162        posture: "do_calculus_aligned".to_string(),
2163        summary: "ai-memory implements the do-calculus intervention/observation \
2164                  distinction at the substrate layer via Form 4 fact-provenance, \
2165                  Form 6 MemoryKind vocabulary, Form 7 agent-EXTERNAL governance, \
2166                  the V-4 signed-events cross-row hash chain, and the seven Gap \
2167                  provenance framework; stops cross-session delusion amplification \
2168                  but not intra-session hallucination (consumer LLM responsibility)."
2169            .to_string(),
2170        enforcement_layers: vec![
2171            "form_4_fact_provenance".to_string(),
2172            "form_6_memory_kind".to_string(),
2173            "form_7_agent_external_governance".to_string(),
2174            "signed_events_v4_chain".to_string(),
2175            "seven_gap_framework".to_string(),
2176        ],
2177        honest_limitations: vec![
2178            "intra_session_hallucination_is_consumer_responsibility".to_string(),
2179            "federation_reliability_via_dlq_not_silent_drop".to_string(),
2180        ],
2181        spec_references: SpecReferences {
2182            do_calculus: "Pearl (2009)".to_string(),
2183            interactional_agency: "Ortega and de Freitas (2026)".to_string(),
2184        },
2185    }
2186}
2187
2188// ---------------------------------------------------------------------------
2189// TTL configuration
2190// ---------------------------------------------------------------------------
2191
2192/// Per-tier TTL overrides loaded from `[ttl]` section of config.toml.
2193#[allow(clippy::struct_field_names)]
2194#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2195pub struct TtlConfig {
2196    /// Short-tier default TTL in seconds (default: 21600 = 6 hours)
2197    pub short_ttl_secs: Option<i64>,
2198    /// Mid-tier default TTL in seconds (default: 604800 = 7 days)
2199    pub mid_ttl_secs: Option<i64>,
2200    /// Long-tier TTL in seconds (default: none = never expires). Set >0 to add expiry.
2201    pub long_ttl_secs: Option<i64>,
2202    /// Short-tier TTL extension on access in seconds (default: 3600 = 1 hour)
2203    pub short_extend_secs: Option<i64>,
2204    /// Mid-tier TTL extension on access in seconds (default: 86400 = 1 day)
2205    pub mid_extend_secs: Option<i64>,
2206}
2207
2208/// Resolved TTL values after merging config overrides with compiled defaults.
2209#[derive(Debug, Clone)]
2210#[allow(clippy::struct_field_names)]
2211pub struct ResolvedTtl {
2212    pub short_ttl_secs: Option<i64>,
2213    pub mid_ttl_secs: Option<i64>,
2214    pub long_ttl_secs: Option<i64>,
2215    pub short_extend_secs: i64,
2216    pub mid_extend_secs: i64,
2217}
2218
2219impl Default for ResolvedTtl {
2220    fn default() -> Self {
2221        Self {
2222            short_ttl_secs: Tier::Short.default_ttl_secs(),
2223            mid_ttl_secs: Tier::Mid.default_ttl_secs(),
2224            long_ttl_secs: Tier::Long.default_ttl_secs(),
2225            short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
2226            mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
2227        }
2228    }
2229}
2230
2231/// Maximum configurable TTL: 10 years in seconds. Prevents integer overflow
2232/// when adding Duration to `Utc::now()`.
2233const MAX_TTL_SECS: i64 = 315_360_000;
2234
2235#[allow(dead_code)]
2236impl ResolvedTtl {
2237    /// Build from optional config overrides, falling back to compiled defaults.
2238    /// TTL values are clamped to `MAX_TTL_SECS` (10 years) to prevent overflow.
2239    /// Extension values are clamped to non-negative.
2240    pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
2241        let defaults = Self::default();
2242        let Some(c) = cfg else {
2243            return defaults;
2244        };
2245        let clamp_ttl = |v: i64| -> Option<i64> {
2246            if v <= 0 {
2247                None
2248            } else {
2249                Some(v.min(MAX_TTL_SECS))
2250            }
2251        };
2252        Self {
2253            short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
2254            mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
2255            long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
2256            short_extend_secs: c
2257                .short_extend_secs
2258                .unwrap_or(defaults.short_extend_secs)
2259                .max(0),
2260            mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
2261        }
2262    }
2263
2264    /// Get the default TTL for a given tier.
2265    pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
2266        match tier {
2267            Tier::Short => self.short_ttl_secs,
2268            Tier::Mid => self.mid_ttl_secs,
2269            Tier::Long => self.long_ttl_secs,
2270        }
2271    }
2272
2273    /// Get the TTL extension on access for a given tier.
2274    pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
2275        match tier {
2276            Tier::Short => Some(self.short_extend_secs),
2277            Tier::Mid => Some(self.mid_extend_secs),
2278            Tier::Long => None,
2279        }
2280    }
2281}
2282
2283// ---------------------------------------------------------------------------
2284// Transcript lifecycle (v0.7.0 I3) — per-namespace TTL + archive→prune
2285// ---------------------------------------------------------------------------
2286
2287/// Compiled-in default for the transcript TTL: 30 days. After this
2288/// many seconds elapse from `created_at` AND every memory that links
2289/// the transcript has expired (or been deleted), the I3 background
2290/// sweeper marks the transcript archived.
2291pub const DEFAULT_TRANSCRIPT_TTL_SECS: i64 = 2_592_000;
2292
2293/// Compiled-in default for the post-archive grace window: 7 days.
2294/// A transcript whose `archived_at` is older than this is hard-deleted
2295/// by the prune phase; the I2 join table is cleaned up via
2296/// `ON DELETE CASCADE`.
2297pub const DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS: i64 = crate::SECS_PER_WEEK;
2298
2299/// Maximum transcript TTL / grace clamp: 10 years in seconds. Mirrors
2300/// [`MAX_TTL_SECS`] above so the same overflow guard applies to the
2301/// transcript lifecycle math when the resolved value flows into a
2302/// `chrono::Duration`.
2303const MAX_TRANSCRIPT_LIFECYCLE_SECS: i64 = 315_360_000;
2304
2305/// `[transcripts]` block in `config.toml` — per-namespace TTL and
2306/// archive grace overrides for the I3 lifecycle sweeper.
2307///
2308/// ```toml
2309/// [transcripts]
2310/// default_ttl_secs   = 2592000   # 30 days; archive after this when memories all expired
2311/// archive_grace_secs = 604800    # 7 days; prune this long after archive
2312///
2313/// [transcripts.namespaces."team/audit"]
2314/// default_ttl_secs = 31536000    # 1 year — compliance retention override
2315///
2316/// [transcripts.namespaces."ephemeral/*"]
2317/// default_ttl_secs = 86400       # 1 day — short-lived scratchpad
2318/// ```
2319///
2320/// Resolution: the sweeper picks the longest-prefix matching namespace
2321/// override (with literal `"*"` patterns last), falls back to the
2322/// global `default_ttl_secs` / `archive_grace_secs` on this struct,
2323/// and finally to the compiled defaults above.
2324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2325pub struct TranscriptsConfig {
2326    /// Global default seconds-since-creation before the sweeper
2327    /// considers a transcript archive-eligible. `None` → compiled
2328    /// default ([`DEFAULT_TRANSCRIPT_TTL_SECS`] = 30 days).
2329    pub default_ttl_secs: Option<i64>,
2330    /// Global default seconds an archived transcript lingers before
2331    /// the prune phase deletes it. `None` → compiled default
2332    /// ([`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`] = 7 days).
2333    pub archive_grace_secs: Option<i64>,
2334    /// Per-namespace overrides keyed by namespace pattern. Patterns
2335    /// are matched literally first; a trailing `/*` selects every
2336    /// child namespace under the prefix; the bare `"*"` is the
2337    /// catch-all and is consulted last.
2338    pub namespaces: Option<std::collections::HashMap<String, TranscriptNamespaceConfig>>,
2339    /// v0.7.0 I1 cap (#628 agent-3 follow-up): the maximum number of
2340    /// bytes a single transcript may decompress to before
2341    /// `transcripts::fetch` rejects it as a decompression bomb. `None`
2342    /// → compiled default ([`crate::transcripts::MAX_DECOMPRESSED_BYTES`]
2343    /// = 16 MiB). Operators with legitimately larger transcripts
2344    /// raise the cap explicitly; the cap is per-call, so concurrent
2345    /// fetches consume up to N × this value of transient memory.
2346    pub max_decompressed_bytes: Option<usize>,
2347}
2348
2349/// Per-namespace overrides nested under
2350/// `[transcripts.namespaces."<pattern>"]`. Each field independently
2351/// overrides the [`TranscriptsConfig`] global default; an unset field
2352/// inherits.
2353#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2354pub struct TranscriptNamespaceConfig {
2355    /// Namespace-specific TTL override.
2356    pub default_ttl_secs: Option<i64>,
2357    /// Namespace-specific archive-grace override.
2358    pub archive_grace_secs: Option<i64>,
2359    /// v0.7 I5 — opt in the namespace to the reference R5 pre_store
2360    /// transcript-extractor hook (`tools/transcript-extractor/`).
2361    /// Default `None` → disabled, matching the "default off" lesson
2362    /// from G3-G11. Operators that wire the extractor binary into
2363    /// their `hooks.toml` set this flag per namespace to gate the
2364    /// derived-memory expansion. `Some(false)` is identical to
2365    /// `None` and exists so an explicit "no, don't extract here"
2366    /// can be expressed alongside a wildcard `Some(true)`.
2367    #[serde(skip_serializing_if = "Option::is_none")]
2368    pub auto_extract: Option<bool>,
2369}
2370
2371/// Resolved transcript-lifecycle parameters for a single namespace.
2372/// Produced by [`TranscriptsConfig::resolve`] and consumed by the I3
2373/// sweeper to drive the archive + prune SQL.
2374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2375pub struct ResolvedTranscriptLifecycle {
2376    /// Seconds-since-creation before archive eligibility. Always
2377    /// positive and `<= MAX_TRANSCRIPT_LIFECYCLE_SECS`.
2378    pub default_ttl_secs: i64,
2379    /// Seconds an archived row lingers before prune. Always
2380    /// positive and `<= MAX_TRANSCRIPT_LIFECYCLE_SECS`.
2381    pub archive_grace_secs: i64,
2382}
2383
2384impl Default for ResolvedTranscriptLifecycle {
2385    fn default() -> Self {
2386        Self {
2387            default_ttl_secs: DEFAULT_TRANSCRIPT_TTL_SECS,
2388            archive_grace_secs: DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS,
2389        }
2390    }
2391}
2392
2393impl TranscriptsConfig {
2394    /// Resolve the lifecycle parameters for `namespace`.
2395    ///
2396    /// Precedence:
2397    /// 1. Exact match in `namespaces` (e.g. `"team/audit"`).
2398    /// 2. Longest matching prefix pattern ending in `/*` (e.g.
2399    ///    `"team/*"` matches `"team/eng"` and `"team/eng/inner"`).
2400    /// 3. Bare `"*"` wildcard.
2401    /// 4. The struct-level `default_ttl_secs` / `archive_grace_secs`.
2402    /// 5. The compiled defaults
2403    ///    ([`DEFAULT_TRANSCRIPT_TTL_SECS`] / [`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`]).
2404    ///
2405    /// Each field is resolved independently — a per-namespace override
2406    /// that only sets `default_ttl_secs` inherits the global
2407    /// `archive_grace_secs`. Non-positive values fall through to the
2408    /// next layer; positive values are clamped to
2409    /// `MAX_TRANSCRIPT_LIFECYCLE_SECS` so the resolved `Duration`
2410    /// addition can never overflow `chrono`.
2411    #[must_use]
2412    pub fn resolve(&self, namespace: &str) -> ResolvedTranscriptLifecycle {
2413        let ns_table = self.namespaces.as_ref();
2414
2415        // Walk the namespace overrides in precedence order, returning
2416        // the first that names the field. `None` means "fall through".
2417        let pick_ns = |field: fn(&TranscriptNamespaceConfig) -> Option<i64>| -> Option<i64> {
2418            let table = ns_table?;
2419            // 1. Exact literal match.
2420            if let Some(ns) = table.get(namespace) {
2421                if let Some(v) = field(ns) {
2422                    return Some(v);
2423                }
2424            }
2425            // 2. Longest-prefix `prefix/*` match.
2426            let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
2427                .iter()
2428                .filter_map(|(k, v)| {
2429                    let prefix = k.strip_suffix("/*")?;
2430                    if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
2431                        Some((prefix, v))
2432                    } else {
2433                        None
2434                    }
2435                })
2436                .collect();
2437            prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
2438            for (_, ns) in &prefix_hits {
2439                if let Some(v) = field(ns) {
2440                    return Some(v);
2441                }
2442            }
2443            // 3. Bare wildcard.
2444            if let Some(ns) = table.get("*") {
2445                if let Some(v) = field(ns) {
2446                    return Some(v);
2447                }
2448            }
2449            None
2450        };
2451
2452        let clamp = |v: i64, fallback: i64| -> i64 {
2453            if v <= 0 {
2454                fallback
2455            } else {
2456                v.min(MAX_TRANSCRIPT_LIFECYCLE_SECS)
2457            }
2458        };
2459
2460        let ttl = pick_ns(|n| n.default_ttl_secs)
2461            .or(self.default_ttl_secs)
2462            .map_or(DEFAULT_TRANSCRIPT_TTL_SECS, |v| {
2463                clamp(v, DEFAULT_TRANSCRIPT_TTL_SECS)
2464            });
2465        let grace = pick_ns(|n| n.archive_grace_secs)
2466            .or(self.archive_grace_secs)
2467            .map_or(DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS, |v| {
2468                clamp(v, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS)
2469            });
2470
2471        ResolvedTranscriptLifecycle {
2472            default_ttl_secs: ttl,
2473            archive_grace_secs: grace,
2474        }
2475    }
2476
2477    /// v0.7 I5 — resolve the `auto_extract` opt-in for `namespace`.
2478    ///
2479    /// Same precedence walk as [`Self::resolve`] but folds the
2480    /// boolean field of [`TranscriptNamespaceConfig::auto_extract`]:
2481    ///
2482    /// 1. Exact match.
2483    /// 2. Longest-prefix `prefix/*` match.
2484    /// 3. Bare wildcard `"*"`.
2485    /// 4. `false` (default off — matches the "every reference hook
2486    ///    ships off-by-default" lesson from G10/G11).
2487    ///
2488    /// The R5 reference extractor (`tools/transcript-extractor/`)
2489    /// reads this flag at the namespace gate before doing any LLM
2490    /// work, so a namespace that hasn't opted in pays the cost of
2491    /// one HashMap lookup per `pre_store` fire and nothing more.
2492    #[must_use]
2493    pub fn auto_extract_for(&self, namespace: &str) -> bool {
2494        let Some(table) = self.namespaces.as_ref() else {
2495            return false;
2496        };
2497        // 1. Exact literal match.
2498        if let Some(ns) = table.get(namespace) {
2499            if let Some(v) = ns.auto_extract {
2500                return v;
2501            }
2502        }
2503        // 2. Longest-prefix `prefix/*` match.
2504        let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
2505            .iter()
2506            .filter_map(|(k, v)| {
2507                let prefix = k.strip_suffix("/*")?;
2508                if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
2509                    Some((prefix, v))
2510                } else {
2511                    None
2512                }
2513            })
2514            .collect();
2515        prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
2516        for (_, ns) in &prefix_hits {
2517            if let Some(v) = ns.auto_extract {
2518                return v;
2519            }
2520        }
2521        // 3. Bare wildcard.
2522        if let Some(ns) = table.get("*") {
2523            if let Some(v) = ns.auto_extract {
2524                return v;
2525            }
2526        }
2527        // 4. Default off.
2528        false
2529    }
2530}
2531
2532// ---------------------------------------------------------------------------
2533// Recall scoring (time-decay half-life) — v0.6.0.0
2534// ---------------------------------------------------------------------------
2535
2536/// Per-tier half-life (days) overrides loaded from `[scoring]` section of
2537/// `config.toml`.
2538///
2539/// The half-life is the number of days it takes for a memory's recall score
2540/// to drop to 50% of its undecayed value. Shorter half-lives prioritize fresh
2541/// memories; longer half-lives give older memories more weight. Defaults are
2542/// chosen so each tier's decay curve matches its retention expectations:
2543/// `short` memories decay quickly (7 d), `mid` moderately (30 d), `long`
2544/// slowly (365 d).
2545///
2546/// Setting `legacy_scoring = true` disables the decay multiplier entirely,
2547/// restoring the pre-v0.6.0.0 blended-score behavior for A/B comparison or
2548/// if a recall-quality regression is reported.
2549#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2550pub struct RecallScoringConfig {
2551    /// Half-life for `short`-tier memories, in days (default 7).
2552    pub half_life_days_short: Option<f64>,
2553    /// Half-life for `mid`-tier memories, in days (default 30).
2554    pub half_life_days_mid: Option<f64>,
2555    /// Half-life for `long`-tier memories, in days (default 365).
2556    pub half_life_days_long: Option<f64>,
2557    /// When true, skip the decay multiplier entirely. Default false.
2558    #[serde(default)]
2559    pub legacy_scoring: bool,
2560}
2561
2562/// Resolved scoring values after merging config overrides with compiled
2563/// defaults. Half-lives are clamped to the range `[0.1, 36_500.0]` days
2564/// (≈100 years) to keep the decay math well-behaved.
2565#[derive(Debug, Clone, Copy)]
2566pub struct ResolvedScoring {
2567    pub half_life_days_short: f64,
2568    pub half_life_days_mid: f64,
2569    pub half_life_days_long: f64,
2570    pub legacy_scoring: bool,
2571}
2572
2573impl Default for ResolvedScoring {
2574    fn default() -> Self {
2575        Self {
2576            half_life_days_short: 7.0,
2577            half_life_days_mid: 30.0,
2578            half_life_days_long: 365.0,
2579            legacy_scoring: false,
2580        }
2581    }
2582}
2583
2584impl ResolvedScoring {
2585    const MIN_HALF_LIFE: f64 = 0.1;
2586    const MAX_HALF_LIFE: f64 = 36_500.0;
2587
2588    /// Build from optional config overrides, falling back to compiled
2589    /// defaults. Out-of-range values are silently clamped.
2590    pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
2591        let defaults = Self::default();
2592        let Some(c) = cfg else {
2593            return defaults;
2594        };
2595        let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
2596        Self {
2597            half_life_days_short: c
2598                .half_life_days_short
2599                .map_or(defaults.half_life_days_short, clamp),
2600            half_life_days_mid: c
2601                .half_life_days_mid
2602                .map_or(defaults.half_life_days_mid, clamp),
2603            half_life_days_long: c
2604                .half_life_days_long
2605                .map_or(defaults.half_life_days_long, clamp),
2606            legacy_scoring: c.legacy_scoring,
2607        }
2608    }
2609
2610    /// Half-life in days for a given tier.
2611    pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
2612        match tier {
2613            Tier::Short => self.half_life_days_short,
2614            Tier::Mid => self.half_life_days_mid,
2615            Tier::Long => self.half_life_days_long,
2616        }
2617    }
2618
2619    /// Compute the decay multiplier `exp(-ln(2) * age_days / half_life)`
2620    /// for a memory of the given tier and age. Returns `1.0` when
2621    /// `legacy_scoring` is true (no decay) or when `age_days` is non-positive
2622    /// (future timestamps, clock skew, or new memories).
2623    #[must_use]
2624    pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
2625        if self.legacy_scoring || age_days <= 0.0 {
2626            return 1.0;
2627        }
2628        let half_life = self.half_life_for_tier(tier);
2629        (-std::f64::consts::LN_2 * age_days / half_life).exp()
2630    }
2631}
2632
2633// ---------------------------------------------------------------------------
2634// Persistent config file (~/.config/ai-memory/config.toml)
2635// ---------------------------------------------------------------------------
2636
2637const CONFIG_DIR: &str = ".config/ai-memory";
2638const CONFIG_FILE: &str = "config.toml";
2639
2640/// Persistent configuration loaded from `~/.config/ai-memory/config.toml`.
2641///
2642/// All fields are optional — CLI flags override file values, which override
2643/// compiled defaults.
2644#[derive(Clone, Default, Serialize, Deserialize)]
2645pub struct AppConfig {
2646    /// Feature tier: keyword, semantic, smart, autonomous
2647    pub tier: Option<String>,
2648    /// Path to the `SQLite` database file
2649    pub db: Option<String>,
2650    /// Ollama base URL for LLM generation (default: <http://localhost:11434>)
2651    ///
2652    /// DOC-6 (FX-C4-batch2, 2026-05-26): legacy flat field, slated
2653    /// for removal in v0.8.0. Use the sectioned `[llm].base_url` /
2654    /// `[embeddings].url` shape from #1146 instead. Run
2655    /// `ai-memory config migrate` to rewrite legacy configs.
2656    #[deprecated(
2657        since = "0.7.0",
2658        note = "use the sectioned `[llm].base_url` / `[embeddings].url` (#1146); slated for removal in v0.8.0"
2659    )]
2660    pub ollama_url: Option<String>,
2661    /// Separate URL for embedding model (defaults to `ollama_url` if unset)
2662    ///
2663    /// DOC-6: legacy; use `[embeddings].url`.
2664    #[deprecated(
2665        since = "0.7.0",
2666        note = "use `[embeddings].url` (#1146); slated for removal in v0.8.0"
2667    )]
2668    pub embed_url: Option<String>,
2669    /// Embedding model override: `mini_lm_l6_v2` or `nomic_embed_v15`
2670    ///
2671    /// DOC-6: legacy; use `[embeddings].model`.
2672    #[deprecated(
2673        since = "0.7.0",
2674        note = "use `[embeddings].model` (#1146); slated for removal in v0.8.0"
2675    )]
2676    pub embedding_model: Option<String>,
2677    /// LLM model override (Ollama tag, e.g. "gemma4:e2b")
2678    ///
2679    /// DOC-6: legacy; use `[llm].model`.
2680    #[deprecated(
2681        since = "0.7.0",
2682        note = "use `[llm].model` (#1146); slated for removal in v0.8.0"
2683    )]
2684    pub llm_model: Option<String>,
2685    /// Dedicated model for auto_tag (and other short-structured LLM calls).
2686    /// Defaults to `gemma3:4b` (fast, deterministic, ~0.7s p50 vs 15s for
2687    /// thinking-mode Gemma 4). Falls back to `llm_model` if unset.
2688    /// See L15 patch (2026-05-11) for rationale.
2689    ///
2690    /// DOC-6: legacy; use `[llm.auto_tag].model`.
2691    #[deprecated(
2692        since = "0.7.0",
2693        note = "use `[llm.auto_tag].model` (#1146); slated for removal in v0.8.0"
2694    )]
2695    pub auto_tag_model: Option<String>,
2696    /// Enable cross-encoder reranking (true/false)
2697    ///
2698    /// DOC-6: legacy; use `[reranker].enabled`.
2699    #[deprecated(
2700        since = "0.7.0",
2701        note = "use `[reranker].enabled` (#1146); slated for removal in v0.8.0"
2702    )]
2703    pub cross_encoder: Option<bool>,
2704    /// Default namespace for new memories
2705    ///
2706    /// DOC-6: legacy; use `[storage].default_namespace`.
2707    #[deprecated(
2708        since = "0.7.0",
2709        note = "use `[storage].default_namespace` (#1146); slated for removal in v0.8.0"
2710    )]
2711    pub default_namespace: Option<String>,
2712    /// Maximum memory budget in MB (used for auto tier selection)
2713    ///
2714    /// DOC-6: legacy; the auto-tier path now resolves via the
2715    /// sectioned `[storage]` block.
2716    #[deprecated(
2717        since = "0.7.0",
2718        note = "auto-tier resolution now resolves via the sectioned [storage] block (#1146); slated for removal in v0.8.0"
2719    )]
2720    pub max_memory_mb: Option<usize>,
2721    /// Per-tier TTL overrides
2722    pub ttl: Option<TtlConfig>,
2723    /// Archive memories before GC deletion (default: true)
2724    ///
2725    /// DOC-6: legacy; use `[storage].archive_on_gc`.
2726    #[deprecated(
2727        since = "0.7.0",
2728        note = "use `[storage].archive_on_gc` (#1146); slated for removal in v0.8.0"
2729    )]
2730    pub archive_on_gc: Option<bool>,
2731    /// Optional API key for HTTP API authentication.
2732    ///
2733    /// #1262 — `skip_serializing` prevents the secret from being
2734    /// echoed back through any `serde_json::to_string(&AppConfig)`
2735    /// path (capabilities overlays, debug dumps, audit traces).
2736    /// #1454 — the manual `Debug` impl on `AppConfig` (just below the
2737    /// struct) renders this field as `<redacted>`, so a `{:?}` of the
2738    /// config never leaks the secret either (`skip_serializing` only
2739    /// guards the serde JSON path, not `Debug`).
2740    /// #1258 — [`AppConfig::zeroize_secrets`] (a free helper method,
2741    /// NOT a blanket `Drop` impl) zeroizes this buffer; callers invoke
2742    /// it immediately before scope-exit. A blanket `Drop` is
2743    /// deliberately avoided so the `..AppConfig::default()`
2744    /// struct-update spread used across ~20 test sites still compiles.
2745    #[serde(default, skip_serializing)]
2746    pub api_key: Option<String>,
2747    /// Maximum archive age in days for automatic purge during GC (default: disabled)
2748    ///
2749    /// DOC-6: legacy; the archive purge knob resolves via the
2750    /// sectioned `[storage]` block at v0.7.x.
2751    #[deprecated(
2752        since = "0.7.0",
2753        note = "archive purge resolution moves under the sectioned [storage] block (#1146); slated for removal in v0.8.0"
2754    )]
2755    pub archive_max_days: Option<i64>,
2756    /// Identity-resolution overrides (Task 1.2 follow-up #198).
2757    pub identity: Option<IdentityConfig>,
2758    /// Recall scoring — per-tier half-life for time-decay, and `legacy_scoring`
2759    /// kill switch (v0.6.0.0).
2760    pub scoring: Option<RecallScoringConfig>,
2761    /// v0.6.0.0: when true, fire LLM autonomy hooks (`auto_tag` +
2762    /// `detect_contradiction`) synchronously on every successful
2763    /// `memory_store`. Off by default — the hook blocks store latency
2764    /// behind an Ollama round-trip. `AI_MEMORY_AUTONOMOUS_HOOKS=1`
2765    /// env var overrides the config file.
2766    pub autonomous_hooks: Option<bool>,
2767    /// v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
2768    /// Default-OFF for privacy; opt-in turns on the rolling file
2769    /// appender that captures every `tracing::*` call site to disk.
2770    pub logging: Option<LoggingConfig>,
2771    /// v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF
2772    /// for privacy; opt-in emits a hash-chained, tamper-evident JSON
2773    /// log of every memory mutation suitable for SIEM ingestion and
2774    /// SOC2 / HIPAA / GDPR / FedRAMP compliance evidence.
2775    pub audit: Option<AuditConfig>,
2776    /// v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy
2777    /// kill-switch. Default-ON (existing users see no behavior change);
2778    /// `[boot] enabled = false` silences boot entirely (empty stdout +
2779    /// empty stderr, exit 0) for privacy-sensitive hosts where memory
2780    /// titles must not enter CI logs. `[boot] redact_titles = true`
2781    /// keeps the manifest header but replaces row titles with
2782    /// `<redacted>` for compliance contexts that need the audit-trail
2783    /// signal of "boot ran with N memories" without exposing subjects.
2784    pub boot: Option<BootConfig>,
2785    /// v0.6.4 — MCP server tunables. Today this only carries `profile`
2786    /// (the named tool surface). Future v0.6.4 phases add the
2787    /// `[mcp.allowlist]` per-agent capability table (Track D —
2788    /// v0.6.4-008).
2789    pub mcp: Option<McpConfig>,
2790    /// v0.7.0 K3 — `[permissions]` block. Drives the gate's enforcement
2791    /// posture (`enforce` / `advisory` / `off`). When unset, the
2792    /// compiled default in [`PermissionsConfig::default`] applies
2793    /// (`advisory` — preserves the v0.6.x honest-disclosure posture
2794    /// where governance metadata was recorded but not blocked at the
2795    /// gate). New installs that want the strict gate set
2796    /// `[permissions] mode = "enforce"` explicitly.
2797    pub permissions: Option<PermissionsConfig>,
2798    /// v0.7.0 I3 — `[transcripts]` block. Per-namespace TTL and
2799    /// archive-grace overrides for the transcript lifecycle sweeper.
2800    /// Unset → compiled defaults apply globally
2801    /// ([`DEFAULT_TRANSCRIPT_TTL_SECS`] / [`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`]).
2802    pub transcripts: Option<TranscriptsConfig>,
2803    /// v0.7.0 K7 — `[hooks]` block. Currently carries the
2804    /// `[hooks.subscription] hmac_secret` server-wide override that
2805    /// signs every outgoing webhook payload regardless of whether the
2806    /// individual subscription supplied a per-subscription secret.
2807    /// When unset, only per-subscription secrets are used (legacy
2808    /// pre-K7 behaviour).
2809    pub hooks: Option<HooksConfig>,
2810    /// v0.7.0 H11 (#628 blocker) — `[subscriptions]` block. Carries
2811    /// the `allow_loopback_webhooks` opt-in that re-enables loopback
2812    /// webhook URLs (`127.0.0.1`, `localhost`, `[::1]`). Default-OFF
2813    /// closes an authenticated SSRF gadget against local services
2814    /// (Postgres on 5432, the hooks daemon, etc.). Operators who need
2815    /// loopback for testing must set this explicitly.
2816    pub subscriptions: Option<SubscriptionsConfig>,
2817    /// v0.7.0 H5 (round-2) — `[verify]` block. Today exposes one
2818    /// knob: `require_nonce` (default `false`). When `true`, every
2819    /// `POST /api/v1/links/verify` request MUST include a
2820    /// `verification_nonce` (UUID v4 expected); missing or replayed
2821    /// nonces are rejected with 409 Conflict. Default-OFF preserves
2822    /// the v0.6.x verify-anytime semantics for unmigrated clients.
2823    pub verify: Option<VerifyConfig>,
2824    /// v0.7.0 M4 — connection-level `statement_timeout` (in seconds)
2825    /// applied via an `after_connect` hook to every postgres
2826    /// connection in the pool. Bounds runaway queries — a pathological
2827    /// `pg_sleep(60)` or an unbounded scan can otherwise wedge a
2828    /// connection forever. Defaults to 30s when unset; set to 0 to
2829    /// disable the limit (matches the postgres `SET` semantics).
2830    /// Operators only need to touch this when the workload requires
2831    /// long-running maintenance queries from the daemon itself.
2832    pub postgres_statement_timeout_secs: Option<u64>,
2833    /// v0.7.0 (a) — connection-pool ceiling (sqlx `max_connections`)
2834    /// for the postgres backend. `None` selects the compiled
2835    /// `DEFAULT_MAX_CONNECTIONS`. Operators tune this per module/daemon
2836    /// without a recompile via `AI_MEMORY_PG_POOL_MAX`. Resolved by
2837    /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2838    /// to the default.
2839    pub postgres_pool_max_connections: Option<u32>,
2840    /// v0.7.0 (a) — connection-pool floor of always-open warm
2841    /// connections (sqlx `min_connections`). `None` selects the
2842    /// compiled `DEFAULT_MIN_CONNECTIONS`. Operator knob:
2843    /// `AI_MEMORY_PG_POOL_MIN`. Resolved by
2844    /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2845    /// to the default.
2846    pub postgres_pool_min_connections: Option<u32>,
2847    /// v0.7.0 (a) — how long a pool `acquire()` waits for a free
2848    /// connection before erroring (sqlx `acquire_timeout`), in whole
2849    /// seconds. `None` selects the compiled default derived from
2850    /// `DEFAULT_ACQUIRE_TIMEOUT`. Operator knob:
2851    /// `AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS`. Resolved by
2852    /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2853    /// to the default.
2854    pub postgres_acquire_timeout_secs: Option<u64>,
2855    /// v0.7.0 H7 (round-2) — per-HTTP-request wall-clock timeout in
2856    /// seconds. Applied as a middleware to every axum route in
2857    /// [`crate::build_router`] so a slow-POST (slowloris-style)
2858    /// attacker cannot keep a handler scope alive indefinitely.
2859    /// `None` selects the compiled default of 60 seconds; operators
2860    /// who need a different ceiling set
2861    /// `request_timeout_secs = <secs>` in `config.toml`.
2862    pub request_timeout_secs: Option<u64>,
2863    /// v0.7.0 H8 (round-2) — per-LLM-call wall-clock timeout in
2864    /// seconds. Wraps every `spawn_blocking` invocation of an Ollama
2865    /// call (`auto_tag`, `expand_query`, `summarize_memories`, ...)
2866    /// in `tokio::time::timeout`. `None` selects the compiled
2867    /// default of 30 seconds; on timeout the call falls back to the
2868    /// LLM-absent path (already exercised by L5/L7).
2869    pub llm_call_timeout_secs: Option<u64>,
2870    /// v0.7.0 (issue #318) — when set, the MCP stdio server forwards
2871    /// every write tool (`memory_store`, `memory_link`, `memory_delete`)
2872    /// to this HTTP endpoint (typically the local `ai-memory serve`
2873    /// daemon at `http://localhost:9077`) instead of writing to SQLite
2874    /// directly. The HTTP daemon then runs the existing
2875    /// `broadcast_store_quorum` / `broadcast_link_quorum` / etc. fanout,
2876    /// closing the gap surfaced by a2a-gate v0.6.0 r6 where MCP-stdio
2877    /// writes replicated locally but never reached the federation mesh.
2878    ///
2879    /// Unset (the default) keeps the legacy direct-SQLite path so
2880    /// single-node MCP deployments without a federation daemon behave
2881    /// exactly as before. The forwarder uses `reqwest::blocking` and
2882    /// surfaces HTTP errors as MCP error strings; on transport failure
2883    /// the response carries the underlying error so operators can
2884    /// distinguish "fanout daemon not running" from "quorum not met".
2885    pub mcp_federation_forward_url: Option<String>,
2886    /// v0.7.0 (issue #518) — `[agents.defaults]` block. Carries the
2887    /// `recall_scope` defaults spliced into `memory_recall` /
2888    /// `GET /api/v1/recall` / `ai-memory recall` requests that pass
2889    /// `session_default=true` (or `--session-default` on the CLI) and
2890    /// omit one or more filter fields. Closes the OpenClaw v0.6.3.1
2891    /// "what were you working on?" recovery gap — agents picking up a
2892    /// new session no longer need to remember to splice the canonical
2893    /// namespace + recency filters on every cross-session recall.
2894    ///
2895    /// `None` (the default) preserves single-tenant deployments and
2896    /// existing recall semantics exactly as-is. The splice happens in
2897    /// the handler before the storage call; explicit args always win
2898    /// over the defaults.
2899    pub agents: Option<AgentsConfig>,
2900    /// v0.7.0 SEC-2 (Cluster D, issue #767) — `[governance]` block.
2901    /// Today exposes one knob: `require_operator_pubkey` (default
2902    /// `false`). When `true`, daemon `serve` startup REFUSES to boot
2903    /// if the `governance_rules` table contains any `enabled = 1`
2904    /// rows AND no operator pubkey is resolved (env var or
2905    /// `~/.config/ai-memory/operator.key.pub`). Closes the
2906    /// fail-OPEN gap where a SQL-write gadget could install
2907    /// `enabled = 1` rules that the pre-L1-6 loader would honour
2908    /// without signature check. Default `false` preserves the
2909    /// pre-cluster-D contract for the install-script deploy where
2910    /// no operator pubkey is yet on disk.
2911    pub governance: Option<GovernanceConfig>,
2912    /// v0.7.0 Cluster G (#767) — `[confidence]` block. Carries the
2913    /// retention window for `confidence_shadow_observations` consumed
2914    /// by the periodic GC sweep (`shadow_retention_days`, default 30).
2915    /// Unset → the compiled default applies. Closes PERF-4: the v0.7.0
2916    /// Form 5 closeout (#758) shipped the shadow-mode table but did
2917    /// NOT ship retention, so a long-running shadow-enabled deployment
2918    /// would see unbounded growth.
2919    pub confidence: Option<ConfidenceConfig>,
2920    /// v0.7.0 SHIP cluster (#946 / #957 / #960 / #961, 2026-05-20) —
2921    /// `[admin]` top-level block. Carries the operator-configured
2922    /// allowlist of `agent_ids` whose authenticated HTTP requests
2923    /// are treated as admin-class callers (full cross-tenant
2924    /// visibility for endpoints that must observe corpus-scale
2925    /// metadata: `GET /api/v1/export`, `GET /api/v1/agents`,
2926    /// `GET /api/v1/stats`, the `POST /api/v1/quota/status` list
2927    /// path). `None` (the default) closes those endpoints to all
2928    /// non-admin callers — the safe-by-default posture per CLAUDE.md
2929    /// `pm-v3`. See [`AdminConfig`] for the full role-gate semantics.
2930    pub admin: Option<AdminConfig>,
2931
2932    // ------------------------------------------------------------------
2933    // v0.7.x enterprise configuration sections (issue #1146).
2934    //
2935    // These four sectioned blocks (`[llm]` / `[embeddings]` /
2936    // `[reranker]` / `[storage]`) consolidate the previously-flat
2937    // LLM / embedder / reranker / storage knobs into named tables with
2938    // a uniform canonical resolver. Legacy flat fields above
2939    // (`llm_model`, `ollama_url`, `embed_url`, `embedding_model`,
2940    // `cross_encoder`, `default_namespace`, `archive_on_gc`,
2941    // `archive_max_days`, `max_memory_mb`) continue to parse and feed
2942    // the resolver's legacy arm with a one-shot deprecation WARN until
2943    // v0.8.0 removes them.
2944    //
2945    // The `schema_version` field carries the explicit shape version.
2946    // Absent / `1` selects the legacy parse path; `>= 2` selects the
2947    // sectioned parse path and warns when legacy fields are also
2948    // present (so an operator who hand-edited the file knows the
2949    // legacy fields are dead weight).
2950    // ------------------------------------------------------------------
2951    /// v0.7.x (#1146) — explicit configuration schema version. `None`
2952    /// or `1` selects the v0.6.x flat-field parse path; `2` selects
2953    /// the sectioned parse path (`[llm]`, `[embeddings]`, `[reranker]`,
2954    /// `[storage]`) and emits a WARN if any legacy flat field is also
2955    /// present. Future bumps (`3`, `4`, …) introduce additional schema
2956    /// transitions and are gated through `ai-memory config migrate`.
2957    pub schema_version: Option<u32>,
2958
2959    /// v0.7.x (#1146) — `[llm]` sectioned LLM configuration. Carries
2960    /// the canonical backend / model / base_url / api_key references
2961    /// consumed by every LLM-init surface (MCP stdio, HTTP daemon,
2962    /// `ai-memory atomise`, `ai-memory curator`, embed-client
2963    /// disambiguator, the boot banner). Resolved via
2964    /// [`AppConfig::resolve_llm`]; the resolver applies the uniform
2965    /// precedence ladder (CLI flag > `AI_MEMORY_LLM_*` env > `[llm]`
2966    /// section > legacy flat fields > compiled default).
2967    ///
2968    /// Includes an optional `[llm.auto_tag]` sub-table for the fast
2969    /// structured-output sibling that handles `auto_tag`, query
2970    /// expansion, and contradiction detection — see [`LlmSection`].
2971    pub llm: Option<LlmSection>,
2972
2973    /// v0.7.x (#1146) — `[embeddings]` sectioned embedding-model
2974    /// configuration. Consumed by the embedder bootstrap in
2975    /// `daemon_runtime` and the MCP embed-client fallback path.
2976    /// Resolved via [`AppConfig::resolve_embeddings`].
2977    pub embeddings: Option<EmbeddingsSection>,
2978
2979    /// v0.7.x (#1146) — `[reranker]` sectioned cross-encoder
2980    /// configuration. Folds the legacy `cross_encoder = bool` knob
2981    /// into a `{ enabled, model }` table with explicit model
2982    /// selection. Resolved via [`AppConfig::resolve_reranker`].
2983    pub reranker: Option<RerankerSection>,
2984
2985    /// v0.7.x (#1146) — `[storage]` sectioned storage configuration.
2986    /// Carries `default_namespace`, `archive_on_gc`, `archive_max_days`,
2987    /// `max_memory_mb` (folded from the previously-flat top-level
2988    /// fields). The `db` path stays top-level per the I4 carve-out in
2989    /// #1146 (path expansion semantics pinned by #507).
2990    pub storage: Option<StorageSection>,
2991
2992    /// v0.7.x — `[limits]` sectioned operator-tunable capacity limits.
2993    /// Carries the per-(agent, namespace) daily memory-write quota, the
2994    /// lifetime storage cap, the daily link-creation quota, and the
2995    /// list/bulk request page-size cap. Resolved via
2996    /// [`AppConfig::resolve_limits`]; the resolver applies the uniform
2997    /// precedence ladder (`AI_MEMORY_MAX_*` env > `[limits]` section >
2998    /// compiled default). Defaults are deliberately generous so the
2999    /// substrate is invisible to small-scale operators; operators with
3000    /// high event-rate workloads raise them per-deployment without
3001    /// recompiling. See [`LimitsSection`].
3002    pub limits: Option<LimitsSection>,
3003}
3004
3005// #1454 (SEC, LOW) — manual `Debug` so the `api_key` secret renders as
3006// `<redacted>` instead of leaking through a `{:?}` of the whole config
3007// (mirrors the `ResolvedLlm` redaction model further down this file).
3008// Every other field is rendered verbatim. KEEP IN SYNC: a new field on
3009// `AppConfig` must be mirrored here or it silently drops from Debug.
3010#[allow(deprecated)] // legacy flat fields are deprecated but still debugged
3011impl std::fmt::Debug for AppConfig {
3012    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3013        f.debug_struct("AppConfig")
3014            .field("tier", &self.tier)
3015            .field("db", &self.db)
3016            .field(config_keys::OLLAMA_URL, &self.ollama_url)
3017            .field("embed_url", &self.embed_url)
3018            .field(config_keys::EMBEDDING_MODEL, &self.embedding_model)
3019            .field("llm_model", &self.llm_model)
3020            .field(config_keys::AUTO_TAG_MODEL, &self.auto_tag_model)
3021            .field(config_keys::CROSS_ENCODER, &self.cross_encoder)
3022            .field(config_keys::DEFAULT_NAMESPACE, &self.default_namespace)
3023            .field(config_keys::MAX_MEMORY_MB, &self.max_memory_mb)
3024            .field("ttl", &self.ttl)
3025            .field(config_keys::ARCHIVE_ON_GC, &self.archive_on_gc)
3026            .field(
3027                "api_key",
3028                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3029            )
3030            .field(config_keys::ARCHIVE_MAX_DAYS, &self.archive_max_days)
3031            .field("identity", &self.identity)
3032            .field("scoring", &self.scoring)
3033            .field("autonomous_hooks", &self.autonomous_hooks)
3034            .field("logging", &self.logging)
3035            .field("audit", &self.audit)
3036            .field("boot", &self.boot)
3037            .field("mcp", &self.mcp)
3038            .field("permissions", &self.permissions)
3039            .field("transcripts", &self.transcripts)
3040            .field("hooks", &self.hooks)
3041            .field("subscriptions", &self.subscriptions)
3042            .field("verify", &self.verify)
3043            .field(
3044                "postgres_statement_timeout_secs",
3045                &self.postgres_statement_timeout_secs,
3046            )
3047            .field(
3048                "postgres_pool_max_connections",
3049                &self.postgres_pool_max_connections,
3050            )
3051            .field(
3052                "postgres_pool_min_connections",
3053                &self.postgres_pool_min_connections,
3054            )
3055            .field(
3056                "postgres_acquire_timeout_secs",
3057                &self.postgres_acquire_timeout_secs,
3058            )
3059            .field("request_timeout_secs", &self.request_timeout_secs)
3060            .field("llm_call_timeout_secs", &self.llm_call_timeout_secs)
3061            .field(
3062                "mcp_federation_forward_url",
3063                &self.mcp_federation_forward_url,
3064            )
3065            .field("agents", &self.agents)
3066            .field("governance", &self.governance)
3067            .field("confidence", &self.confidence)
3068            .field("admin", &self.admin)
3069            .field("schema_version", &self.schema_version)
3070            .field("llm", &self.llm)
3071            .field(config_keys::SECTION_EMBEDDINGS, &self.embeddings)
3072            .field("reranker", &self.reranker)
3073            .field("storage", &self.storage)
3074            .field("limits", &self.limits)
3075            .finish()
3076    }
3077}
3078
3079impl AppConfig {
3080    /// #1258 — manually zeroize the `api_key` buffer. Callers that hold
3081    /// the only owner of an `AppConfig` and are about to drop it
3082    /// invoke this immediately before scope-exit so the secret bytes
3083    /// do not linger on the heap. The free-standing helper (instead of
3084    /// a blanket `Drop` impl on `AppConfig`) preserves the
3085    /// `..AppConfig::default()` struct-update syntax used by ~20
3086    /// existing test sites; adding a blanket `Drop` would forbid the
3087    /// move-by-spread pattern Rust requires for `Drop` types.
3088    pub fn zeroize_secrets(&mut self) {
3089        use zeroize::Zeroize;
3090        if let Some(key) = self.api_key.as_mut() {
3091            key.zeroize();
3092        }
3093    }
3094}
3095
3096/// v0.7.0 SEC-2 (Cluster D, issue #767) — `[governance]` top-level
3097/// block. Today exposes a single fail-closed knob; future governance
3098/// knobs (e.g., signature-rotation policy timestamps, per-rule
3099/// override timeouts) can stack here.
3100///
3101/// Wire format:
3102/// ```toml
3103/// [governance]
3104/// require_operator_pubkey = true
3105/// ```
3106#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3107pub struct GovernanceConfig {
3108    /// SEC-2 fail-closed switch. When `true`, the daemon refuses to
3109    /// start if the `governance_rules` table contains any
3110    /// `enabled = 1` row AND no operator pubkey is resolved. Default
3111    /// `false` preserves the pre-cluster-D contract that the
3112    /// substrate stays in pre-L1-6 mode (every enabled rule passes
3113    /// through) until the operator activates L1-6 by placing the
3114    /// pubkey on disk or setting `AI_MEMORY_OPERATOR_PUBKEY`.
3115    ///
3116    /// Operators running the install-script default deploy who want
3117    /// strict enforcement BEFORE the operator pubkey lands set this
3118    /// to `true` — the daemon will then surface a clear error
3119    /// message naming the missing pubkey path.
3120    #[serde(default)]
3121    pub require_operator_pubkey: bool,
3122}
3123
3124/// v0.7.0 SHIP cluster (#946 / #957 / #960 / #961, 2026-05-20) —
3125/// `[admin]` top-level block. The operator-configured allowlist of
3126/// `agent_ids` whose authenticated HTTP requests are treated as
3127/// admin-class callers, granting full cross-tenant visibility on
3128/// endpoints whose payloads necessarily expose corpus-scale
3129/// metadata (`GET /api/v1/export`, `GET /api/v1/agents`,
3130/// `GET /api/v1/stats`, the `POST /api/v1/quota/status` list path).
3131///
3132/// Wire format:
3133/// ```toml
3134/// [admin]
3135/// agent_ids = ["ops:admin", "ai:claude@workstation"]
3136/// ```
3137///
3138/// **Default-closed.** When the block is absent, the allowlist is
3139/// empty and every admin-class endpoint returns `403 Forbidden` for
3140/// every caller. Operators MUST set `[admin].agent_ids = [...]`
3141/// explicitly to grant any caller admin privileges. This closes
3142/// the v0.7.0 SHIP-blocking cross-tenant exfiltration defects
3143/// (#946 / #957 / #960) where admin endpoints landed open by default
3144/// because the legacy `api_key_auth` middleware passes through when
3145/// no API key is configured.
3146///
3147/// **Caller resolution** uses the same primitive other handlers do
3148/// (`identity::resolve_http_agent_id` against `X-Agent-Id`). The
3149/// allowlist matches against the resolved caller string verbatim;
3150/// there is no glob / prefix support today (planned under #961 when
3151/// the operator surface grows beyond a static list).
3152///
3153/// **Not a substitute for authentication.** The role gate runs
3154/// AFTER `api_key_auth`. Deployments serving sensitive corpora
3155/// MUST set `api_key` so the bare-network surface requires the key
3156/// AND the role gate runs on top of it. The two layers compose:
3157/// `api_key_auth` answers "is the request authenticated?" and the
3158/// admin gate answers "is the authenticated caller an admin?".
3159#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3160pub struct AdminConfig {
3161    /// Explicit list of `agent_id` strings whose authenticated
3162    /// requests are treated as admin-class. Default `vec![]`
3163    /// (empty) means no caller is an admin — every admin-class
3164    /// endpoint returns 403.
3165    ///
3166    /// Each entry MUST match a caller's resolved `agent_id`
3167    /// verbatim. Validation: the SAL accepts the same NHI
3168    /// `agent_id` charset that
3169    /// [`crate::validate::validate_agent_id`] enforces (see the
3170    /// "Agent Identity (NHI)" section of CLAUDE.md). Entries that
3171    /// fail validation at boot are logged at `warn` and dropped
3172    /// from the in-memory allowlist; the daemon still starts so
3173    /// a single typo does not lock the operator out.
3174    #[serde(default)]
3175    pub agent_ids: Vec<String>,
3176}
3177
3178impl AdminConfig {
3179    /// Returns the validated subset of `agent_ids` — entries that
3180    /// pass [`crate::validate::validate_agent_id`]. Entries that
3181    /// fail validation are dropped (with a `warn` log) so a single
3182    /// typo in `config.toml` cannot lock the operator out.
3183    #[must_use]
3184    pub fn validated_agent_ids(&self) -> Vec<String> {
3185        let mut out = Vec::with_capacity(self.agent_ids.len());
3186        for id in &self.agent_ids {
3187            match crate::validate::validate_agent_id(id) {
3188                Ok(()) => out.push(id.clone()),
3189                Err(e) => {
3190                    tracing::warn!("[admin] dropping invalid agent_id '{id}' from allowlist: {e}");
3191                }
3192            }
3193        }
3194        out
3195    }
3196}
3197
3198// ---------------------------------------------------------------------------
3199// v0.7.x enterprise configuration sections (issue #1146)
3200//
3201// `[llm]` / `[embeddings]` / `[reranker]` / `[storage]` consolidate
3202// previously-flat LLM / embedder / reranker / storage knobs into a
3203// uniform sectioned shape consumed by the canonical resolvers in
3204// `impl AppConfig`. See the issue for the full design rationale,
3205// migration plan, and acceptance criteria.
3206// ---------------------------------------------------------------------------
3207
3208/// v0.7.x (#1146) — `[llm]` sectioned LLM configuration.
3209///
3210/// Wire format:
3211/// ```toml
3212/// [llm]
3213/// backend     = "xai"          # ollama | openai | xai | anthropic | gemini | …
3214/// model       = "grok-4.3"     # vendor-specific identifier
3215/// base_url    = "https://api.x.ai/v1"   # optional; vendor-default if unset
3216/// api_key_env = "XAI_API_KEY"           # env var name (mutually exclusive
3217///                                        # with api_key_file)
3218/// # api_key_file = "/etc/ai-memory/keys/xai.key"   # mode 0400 enforced
3219///
3220/// [llm.auto_tag]
3221/// # Fast structured-output sibling (auto_tag, query expansion,
3222/// # contradiction detection). Fields fall back to parent [llm]
3223/// # field-by-field when unset; commonly only `model` is overridden.
3224/// model = "gemma3:4b"
3225/// ```
3226///
3227/// **Secret handling discipline.** Inline `api_key = "<literal>"` is
3228/// REJECTED at parse time — operators MUST use either
3229/// `api_key_env = "<ENV_VAR_NAME>"` (resolved at runtime) or
3230/// `api_key_file = "/path/to/key"` (mode 0400 enforced, override via
3231/// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1`). Both unset selects
3232/// the per-vendor-alias env-var fallback chain (see `src/llm.rs`
3233/// `alias_api_key_env_vars`).
3234///
3235/// **Precedence.** Resolved via [`AppConfig::resolve_llm`] through the
3236/// uniform precedence ladder: CLI flag > `AI_MEMORY_LLM_*` env vars >
3237/// `[llm]` section > legacy flat fields (`llm_model`, `ollama_url`) >
3238/// compiled default (warn-logged once on the resolver's `CompiledDefault`
3239/// arm).
3240#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3241pub struct LlmSection {
3242    /// Backend selector. One of: `ollama` (native `/api/chat` +
3243    /// `/api/embed`, no auth), `openai-compatible` (generic; requires
3244    /// explicit `base_url`), or an alias that pre-fills `base_url`
3245    /// (`openai`, `xai`, `anthropic`, `gemini`, `deepseek`, `kimi`,
3246    /// `qwen`, `mistral`, `groq`, `together`, `cerebras`, `openrouter`,
3247    /// `fireworks`, `lmstudio`). Unset = inherit legacy resolution
3248    /// (treated as `ollama`).
3249    pub backend: Option<String>,
3250
3251    /// Model identifier passed verbatim to the chat endpoint.
3252    /// Vendor-specific (e.g., `grok-4.3`, `gpt-5`, `claude-opus-4.7`).
3253    /// Unset = backend-specific default (see `OllamaClient::from_env`).
3254    pub model: Option<String>,
3255
3256    /// Optional base-URL override. Required when `backend =
3257    /// "openai-compatible"`; ignored otherwise (vendor-default
3258    /// applies). For `backend = "ollama"`, defaults to
3259    /// `http://localhost:11434`.
3260    pub base_url: Option<String>,
3261
3262    /// Name of the environment variable to read at runtime for the
3263    /// API-key Bearer auth secret. Mutually exclusive with
3264    /// `api_key_file`. Example: `api_key_env = "XAI_API_KEY"`. The
3265    /// `AI_MEMORY_LLM_API_KEY` process-env override (and the
3266    /// per-vendor fallback chain at `src/llm.rs`
3267    /// `alias_api_key_env_vars`) take precedence over this field per
3268    /// the uniform precedence ladder.
3269    pub api_key_env: Option<String>,
3270
3271    /// Path to a file whose first line is the API-key Bearer secret.
3272    /// Mutually exclusive with `api_key_env`. File must be `mode 0400`
3273    /// or stricter (overridable via
3274    /// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1` per #1055). Tilde
3275    /// expansion applies.
3276    pub api_key_file: Option<String>,
3277
3278    /// **REJECTED AT PARSE TIME.** Accepting the field name here lets
3279    /// the validator emit a clear "use api_key_env or api_key_file"
3280    /// error instead of serde's generic "unknown field". Operators
3281    /// inlining secrets in the config file see the security-rationale
3282    /// message at load time.
3283    #[serde(default)]
3284    pub api_key: Option<String>,
3285
3286    /// `[llm.auto_tag]` sub-table for the fast structured-output
3287    /// sibling (`auto_tag`, query expansion, contradiction detection).
3288    /// Unset = inherit every field from the parent [`LlmSection`].
3289    /// When set, only the explicitly-provided fields override; unset
3290    /// fields fall back to the parent.
3291    #[serde(default)]
3292    pub auto_tag: Option<LlmAutoTagSection>,
3293}
3294
3295// #1454 (SEC, LOW) — manual `Debug` redacts the parse-time-rejected
3296// inline `api_key` so a `{:?}` of an `LlmSection` never echoes a secret
3297// (mirrors `ResolvedLlm`). `api_key_env` / `api_key_file` are env-var
3298// names / file paths (config, not secret) and stay verbatim. KEEP IN
3299// SYNC: a new field must be mirrored here or it drops from Debug.
3300impl std::fmt::Debug for LlmSection {
3301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3302        f.debug_struct("LlmSection")
3303            .field("backend", &self.backend)
3304            .field("model", &self.model)
3305            .field("base_url", &self.base_url)
3306            .field("api_key_env", &self.api_key_env)
3307            .field("api_key_file", &self.api_key_file)
3308            .field(
3309                "api_key",
3310                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3311            )
3312            .field("auto_tag", &self.auto_tag)
3313            .finish()
3314    }
3315}
3316
3317/// v0.7.x (#1146) — `[llm.auto_tag]` sub-table. Fast structured-output
3318/// sibling of [`LlmSection`]. Fields fall back to the parent `[llm]`
3319/// section field-by-field when unset; commonly only `model` is
3320/// overridden to point at a faster model (default `gemma3:4b`,
3321/// ~0.7s p50 vs ~15s p50 for thinking-mode Gemma 4 per L15 patch).
3322#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3323pub struct LlmAutoTagSection {
3324    /// Backend override. Unset = inherit `[llm].backend`.
3325    pub backend: Option<String>,
3326    /// Model override. Unset = inherit `[llm].model`. Compiled default
3327    /// at the resolver level is `gemma3:4b` (L15 fast-structured-output
3328    /// model selection).
3329    pub model: Option<String>,
3330    /// Base-URL override. Unset = inherit `[llm].base_url`.
3331    pub base_url: Option<String>,
3332    /// Env-var-name override for the API key. Unset = inherit
3333    /// `[llm].api_key_env` (or `[llm].api_key_file`).
3334    pub api_key_env: Option<String>,
3335    /// File-path override for the API key. Unset = inherit
3336    /// `[llm].api_key_file` (or `[llm].api_key_env`).
3337    pub api_key_file: Option<String>,
3338}
3339
3340/// v0.7.x (#1146) — `[embeddings]` sectioned embedding-model
3341/// configuration.
3342///
3343/// Wire format:
3344/// ```toml
3345/// [embeddings]
3346/// backend        = "openrouter"                # ollama (default) or any
3347///                                              # #1067 API alias /
3348///                                              # openai-compatible (#1598)
3349/// base_url       = "https://openrouter.ai/api/v1"
3350/// model          = "google/gemini-embedding-2"
3351/// api_key_env    = "OPENROUTER_API_KEY"        # mutually exclusive with
3352/// # api_key_file = "/etc/ai-memory/keys/embed.key"   # mode 0400 enforced
3353/// dim            = 3072                        # only needed for models
3354///                                              # outside the known-dims table
3355/// backfill_batch = 100                         # 1-10000 (env override:
3356///                                              # AI_MEMORY_EMBED_BACKFILL_BATCH)
3357/// ```
3358#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3359pub struct EmbeddingsSection {
3360    /// Embedding backend. `ollama` (the default — local `/api/embed`
3361    /// wire shape) or, since #1598, any #1067 OpenAI-compatible alias
3362    /// (`openrouter`, `openai`, `gemini`, …) or the generic
3363    /// `openai-compatible` escape hatch for self-hosted endpoints
3364    /// (HF TEI, vLLM).
3365    pub backend: Option<String>,
3366
3367    /// Embedding endpoint URL. Defaults to `http://localhost:11434`
3368    /// when unset (ollama backend) or to the backend alias's default
3369    /// base URL (API backends, #1598). Synonym of [`Self::base_url`];
3370    /// `base_url` wins when both are set.
3371    pub url: Option<String>,
3372
3373    /// #1598 — embedding endpoint base URL. Synonym of [`Self::url`]
3374    /// (named to match `[llm].base_url`); when both are set,
3375    /// `base_url` wins.
3376    pub base_url: Option<String>,
3377
3378    /// Embedding model identifier. Legacy values `nomic_embed_v15`
3379    /// (alias for `nomic-embed-text-v1.5`) and `mini_lm_l6_v2` (alias
3380    /// for `sentence-transformers/all-MiniLM-L6-v2`) are honored at
3381    /// parse time.
3382    pub model: Option<String>,
3383
3384    /// #1598 — inline API-key literal. ALWAYS REJECTED at config load
3385    /// (mirrors `[llm].api_key`): config.toml is typically
3386    /// world-readable, so inline secrets are a credential leak. The
3387    /// field exists solely so the rejection is loud instead of a
3388    /// silent unknown-key skip. Use [`Self::api_key_env`] or
3389    /// [`Self::api_key_file`].
3390    pub api_key: Option<String>,
3391
3392    /// #1598 — name of the process env var holding the embedding API
3393    /// key. Mutually exclusive with [`Self::api_key_file`].
3394    pub api_key_env: Option<String>,
3395
3396    /// #1598 — path of a file holding the embedding API key (mode
3397    /// 0400 enforced, mirroring `[llm].api_key_file`). Mutually
3398    /// exclusive with [`Self::api_key_env`].
3399    pub api_key_file: Option<String>,
3400
3401    /// #1598 — explicit vector-dim override for embedding models not
3402    /// in [`KNOWN_EMBEDDING_DIMS`]. Takes precedence over the table
3403    /// lookup; non-positive values are ignored.
3404    pub dim: Option<u32>,
3405
3406    /// Backfill batch size. Bounded `1..=10000`; out-of-range values
3407    /// fall back to the compiled default (100) with a WARN. Env
3408    /// override: `AI_MEMORY_EMBED_BACKFILL_BATCH` (#38).
3409    pub backfill_batch: Option<u32>,
3410}
3411
3412/// v0.7.x (#1146) — `[reranker]` sectioned cross-encoder
3413/// configuration.
3414///
3415/// Wire format:
3416/// ```toml
3417/// [reranker]
3418/// enabled = true
3419/// model   = "ms-marco-MiniLM-L-6-v2"   # v0.7.0 has one variant;
3420///                                       # field reserved for future
3421///                                       # bake-offs.
3422/// ```
3423///
3424/// Folds the legacy `cross_encoder = bool` top-level flag. Migration
3425/// (via `ai-memory config migrate`) writes the explicit `enabled` +
3426/// `model` fold; the legacy field continues to be honored at parse
3427/// time until v0.8.0.
3428#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3429pub struct RerankerSection {
3430    /// Whether the cross-encoder rerank stage runs in the recall
3431    /// pipeline. Folded from `cross_encoder: Option<bool>` at the
3432    /// resolver layer.
3433    pub enabled: Option<bool>,
3434
3435    /// Cross-encoder model identifier. Defaults to
3436    /// `ms-marco-MiniLM-L-6-v2` when unset. Field reserved for future
3437    /// model bake-offs (e.g., `bge-reranker-v2-m3`,
3438    /// `mxbai-rerank-large-v2`).
3439    pub model: Option<String>,
3440
3441    /// #1604 — tokenized length cap for rerank inputs (the batched
3442    /// cross-encoder forward). Defaults to
3443    /// `crate::reranker::RERANK_MAX_SEQ_DEFAULT` when unset; values
3444    /// that are zero or above the model ceiling
3445    /// (`crate::reranker::CROSS_ENCODER_MAX_SEQ`) fall through.
3446    /// Overridable via `AI_MEMORY_RERANK_MAX_SEQ`.
3447    pub max_seq_tokens: Option<usize>,
3448}
3449
3450/// v0.7.x (#1146) — `[storage]` sectioned storage configuration.
3451///
3452/// Wire format:
3453/// ```toml
3454/// [storage]
3455/// default_namespace = "alphaone"
3456/// archive_on_gc     = true
3457/// archive_max_days  = 90
3458/// max_memory_mb     = 4096
3459/// ```
3460///
3461/// Carries the previously-flat top-level fields `default_namespace`,
3462/// `archive_on_gc`, `archive_max_days`, `max_memory_mb`. The `db`
3463/// path stays top-level per the #1146 I4 carve-out (path expansion
3464/// semantics pinned by #507).
3465#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3466pub struct StorageSection {
3467    /// Default namespace for new memories when the caller's request
3468    /// omits one. Folded from the previously-flat top-level
3469    /// `default_namespace` field.
3470    pub default_namespace: Option<String>,
3471
3472    /// Whether to archive memories before GC deletion. Folded from
3473    /// `archive_on_gc`. Default `true`.
3474    pub archive_on_gc: Option<bool>,
3475
3476    /// Archive retention ceiling in days. `None` (default) disables
3477    /// the automatic purge. Folded from `archive_max_days`.
3478    pub archive_max_days: Option<i64>,
3479
3480    /// Memory budget in MB for the auto tier selector. Folded from
3481    /// `max_memory_mb`.
3482    pub max_memory_mb: Option<usize>,
3483
3484    /// #1579 B7 — sqlite `PRAGMA mmap_size` in bytes. `0` disables
3485    /// memory-mapped I/O (stock SQLite semantics); negative values are
3486    /// treated as unset and fall through the ladder. Env override:
3487    /// `AI_MEMORY_DB_MMAP_SIZE` (see [`ENV_DB_MMAP_SIZE`]). Compiled
3488    /// default: 256 MiB
3489    /// ([`crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES`]) — the only
3490    /// across-the-board winner of the P1 perf-audit PRAGMA A/B
3491    /// (15-30% on large-corpus reads).
3492    pub db_mmap_size_bytes: Option<i64>,
3493}
3494
3495/// v0.7.x — `[limits]` sectioned operator-tunable capacity limits.
3496///
3497/// Wire format:
3498/// ```toml
3499/// [limits]
3500/// max_memories_per_day = 10000000   # per-(agent, namespace) daily write quota
3501/// max_storage_bytes    = 1073741824 # per-(agent, namespace) lifetime byte cap
3502/// max_links_per_day    = 5000       # per-(agent, namespace) daily link quota
3503/// max_page_size        = 1000       # list/bulk request page-size ceiling
3504/// ```
3505///
3506/// Every field is optional; an omitted (or non-positive) value falls
3507/// through to the compiled default (`crate::quotas::DEFAULT_MAX_*` for
3508/// the three quota knobs, [`crate::handlers::MAX_BULK_SIZE`] for the
3509/// page-size cap). Resolved via [`AppConfig::resolve_limits`], which
3510/// also honours the `AI_MEMORY_MAX_*` env overrides at higher
3511/// precedence than the section.
3512///
3513/// **Operator guidance for `max_page_size`.** This bounds the number of
3514/// rows materialised into a single HTTP list response AND the number of
3515/// items accepted in a single bulk / federation-sync request. It is a
3516/// per-request in-memory bound, NOT a rate limit: a single request that
3517/// asks for (or carries) millions of rows allocates them all at once.
3518/// Raise it for bulk verification of a known-small corpus; for
3519/// genuinely large datasets paginate with `?offset=` / `?since=` rather
3520/// than removing the bound.
3521#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3522pub struct LimitsSection {
3523    /// Per-(agent, namespace) daily memory-write ceiling stamped at
3524    /// quota-row auto-insert. Folds nothing legacy; new at v0.7.x.
3525    pub max_memories_per_day: Option<i64>,
3526
3527    /// Per-(agent, namespace) lifetime storage cap in bytes.
3528    pub max_storage_bytes: Option<i64>,
3529
3530    /// Per-(agent, namespace) daily link-creation ceiling.
3531    pub max_links_per_day: Option<i64>,
3532
3533    /// Maximum items returned in a single list response / accepted in a
3534    /// single bulk or federation-sync request.
3535    pub max_page_size: Option<usize>,
3536}
3537
3538// ---------------------------------------------------------------------------
3539// Resolved-config shapes (#1146)
3540//
3541// Every surface that needs LLM / embedder / reranker / storage config
3542// consumes one of the `Resolved*` shapes below. The resolver methods on
3543// `AppConfig` (`resolve_llm` / `resolve_embeddings` / `resolve_reranker`
3544// / `resolve_storage`) produce them by applying the uniform precedence
3545// ladder:
3546//
3547//   CLI flag  >  AI_MEMORY_* env var  >  config.toml section
3548//             >  legacy flat fields (with deprecation WARN once)
3549//             >  compiled default (CompiledDefault arm, WARN once)
3550//
3551// Resolvers are PURE (no network I/O). File reads for `api_key_file`
3552// happen at resolve time and surface errors via the `KeySource::Error`
3553// variant rather than panicking, so the daemon can boot and report the
3554// problem via the doctor reachability probe rather than failing at
3555// load time.
3556// ---------------------------------------------------------------------------
3557
3558/// Provenance tag for a resolved `Resolved*` field's value, surfaced by
3559/// the boot banner and `ai-memory doctor` so operators can see WHICH
3560/// source won the precedence ladder.
3561#[derive(Debug, Clone, PartialEq, Eq)]
3562pub enum ConfigSource {
3563    /// CLI flag (highest precedence).
3564    Cli,
3565    /// `AI_MEMORY_*` process environment variable.
3566    Env,
3567    /// `[llm]` / `[embeddings]` / `[reranker]` / `[storage]` section
3568    /// in `~/.config/ai-memory/config.toml`.
3569    Config,
3570    /// Legacy flat field in `~/.config/ai-memory/config.toml` (e.g.
3571    /// `llm_model = "gemma4:e4b"`). Triggers a one-shot deprecation
3572    /// WARN on `Config::load`.
3573    Legacy,
3574    /// Compiled-in default (no operator configuration). Triggers a
3575    /// one-shot WARN at resolve time so silent misconfigurations are
3576    /// loud.
3577    CompiledDefault,
3578}
3579
3580impl ConfigSource {
3581    #[must_use]
3582    pub fn as_str(&self) -> &'static str {
3583        match self {
3584            Self::Cli => "cli",
3585            Self::Env => "env",
3586            Self::Config => "config",
3587            Self::Legacy => "legacy",
3588            Self::CompiledDefault => "compiled-default",
3589        }
3590    }
3591}
3592
3593/// Provenance tag for a resolved API-key value.
3594#[derive(Debug, Clone, PartialEq, Eq)]
3595pub enum KeySource {
3596    /// `AI_MEMORY_LLM_API_KEY` process env var (highest precedence).
3597    ProcessEnv,
3598    /// Per-vendor process env-var fallback (`XAI_API_KEY`,
3599    /// `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.). The string field
3600    /// carries the name of the var that won (for observability).
3601    AliasFallback(String),
3602    /// `[llm].api_key_env` config-pointed env var. The string field
3603    /// carries the resolved env-var name.
3604    ConfigEnvVar(String),
3605    /// `[llm].api_key_file` config-pointed file path. The string field
3606    /// carries the resolved (tilde-expanded) path.
3607    ConfigFile(String),
3608    /// No API key resolved. Correct for `backend = "ollama"`
3609    /// (no auth); a misconfiguration for OpenAI-compatible vendors.
3610    None,
3611    /// Error reading the resolved key source. The string carries the
3612    /// human-readable error for the doctor probe to surface.
3613    Error(String),
3614}
3615
3616impl KeySource {
3617    #[must_use]
3618    pub fn as_str(&self) -> &'static str {
3619        match self {
3620            Self::ProcessEnv => "process-env",
3621            Self::AliasFallback(_) => "alias-fallback",
3622            Self::ConfigEnvVar(_) => "config-env-var",
3623            Self::ConfigFile(_) => "config-file",
3624            Self::None => "none",
3625            Self::Error(_) => "error",
3626        }
3627    }
3628
3629    /// True when the key was resolved from any source.
3630    #[must_use]
3631    pub fn is_present(&self) -> bool {
3632        !matches!(self, Self::None | Self::Error(_))
3633    }
3634}
3635
3636/// Canonical resolved-LLM configuration. Produced by
3637/// [`AppConfig::resolve_llm`]. Every LLM-init surface (MCP stdio,
3638/// HTTP daemon, `ai-memory atomise`, `ai-memory curator`,
3639/// embed-client fallback, boot banner) consumes this struct rather
3640/// than reading raw config / env / tier presets.
3641///
3642/// **Secret handling.** The `api_key` field is private; access via
3643/// `api_key()`. The `Debug` impl redacts the value (`<redacted>`).
3644#[derive(Clone, PartialEq, Eq)]
3645pub struct ResolvedLlm {
3646    /// Backend alias / wire-shape selector (e.g. `"ollama"`, `"xai"`,
3647    /// `"openai-compatible"`).
3648    pub backend: String,
3649    /// Model identifier passed verbatim to the chat endpoint.
3650    pub model: String,
3651    /// Base URL of the chat endpoint (vendor-default or operator
3652    /// override).
3653    pub base_url: String,
3654    /// Resolved API key. `None` for `backend = "ollama"` and for
3655    /// misconfigured backends; `Some` otherwise. Private — access via
3656    /// [`Self::api_key`] to keep accidental `{:?}` prints from
3657    /// leaking the value.
3658    api_key: Option<String>,
3659    /// Provenance of the resolved API key for boot-banner /
3660    /// doctor-probe display.
3661    pub api_key_source: KeySource,
3662    /// Provenance of the resolved configuration (CLI / env / config /
3663    /// legacy / compiled-default).
3664    pub source: ConfigSource,
3665}
3666
3667impl ResolvedLlm {
3668    /// Access the resolved API key. Use this only when constructing
3669    /// the LLM client; do NOT log or `{:?}` the result.
3670    #[must_use]
3671    pub fn api_key(&self) -> Option<&str> {
3672        self.api_key.as_deref()
3673    }
3674
3675    /// True when the resolved backend uses the Ollama-native wire
3676    /// shape (`/api/chat`, `/api/embed`, no auth). False for any
3677    /// OpenAI-compatible vendor.
3678    ///
3679    /// Compares `self.backend` against the canonical
3680    /// [`crate::llm::BACKEND_OLLAMA`] selector (#1174 PR4 substrate
3681    /// cleanup) so the literal lives in `llm.rs` alongside the rest
3682    /// of the vendor-alias tables instead of being re-named at each
3683    /// substrate site.
3684    #[must_use]
3685    pub fn is_ollama_native(&self) -> bool {
3686        self.backend == crate::llm::BACKEND_OLLAMA
3687    }
3688
3689    /// Display string for the boot banner: `<backend>:<model>`.
3690    #[must_use]
3691    pub fn display_label(&self) -> String {
3692        format!("{}:{}", self.backend, self.model)
3693    }
3694}
3695
3696impl std::fmt::Debug for ResolvedLlm {
3697    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3698        f.debug_struct("ResolvedLlm")
3699            .field("backend", &self.backend)
3700            .field("model", &self.model)
3701            .field("base_url", &self.base_url)
3702            .field(
3703                "api_key",
3704                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3705            )
3706            .field("api_key_source", &self.api_key_source)
3707            .field("source", &self.source)
3708            .finish()
3709    }
3710}
3711
3712/// Canonical resolved-embedder configuration. Produced by
3713/// [`AppConfig::resolve_embeddings`].
3714///
3715/// **Secret handling (#1598).** The `api_key` field is private;
3716/// access via [`Self::api_key`]. The manual `Debug` impl redacts the
3717/// value (`<redacted>`), mirroring [`ResolvedLlm`].
3718#[derive(Clone, PartialEq, Eq)]
3719pub struct ResolvedEmbeddings {
3720    /// Embedding backend selector. `"ollama"` (local `/api/embed`
3721    /// wire shape) or, since #1598, any #1067 OpenAI-compatible alias
3722    /// / the generic `openai-compatible` escape hatch. Classify via
3723    /// [`is_api_embed_backend`].
3724    pub backend: String,
3725    /// Embedding endpoint base URL. The `[embeddings].base_url` /
3726    /// `[embeddings].url` synonym merge happens in the resolver
3727    /// (`base_url` wins); the field keeps the historical `url` name
3728    /// to limit call-site churn (#1598).
3729    pub url: String,
3730    /// Embedding model identifier (canonicalised — legacy aliases
3731    /// `nomic_embed_v15` / `mini_lm_l6_v2` are mapped to the
3732    /// `EmbeddingModel` enum's canonical HF id at resolve time).
3733    pub model: String,
3734    /// Backfill batch size. Bounded `1..=10000`; out-of-range values
3735    /// fall back to 100 with a WARN.
3736    pub backfill_batch: u32,
3737    /// v0.7.x (issue #1169) — vector dim of the resolved model, when
3738    /// known. #1598: the explicit `[embeddings].dim` override wins
3739    /// over the [`canonical_embedding_dim`] table lookup. `None` when
3740    /// the operator chose a model id that isn't in the table and set
3741    /// no override — in that case [`build_capability_models`] falls
3742    /// back to the tier preset's dim (preserving pre-#1169 behaviour
3743    /// for unrecognised ids and avoiding the silent-wrong-dim trap
3744    /// for the recognised ones).
3745    pub embedding_dim: Option<u32>,
3746    /// #1598 (fleet follow-up) — the EXPLICIT `[embeddings].dim`
3747    /// override only (never table-derived). For OpenAI-compatible
3748    /// backends this is also sent as the wire `dimensions` request
3749    /// param, so Matryoshka-capable API models (gemini-embedding-2,
3750    /// text-embedding-3-*) return truncated vectors at the operator's
3751    /// declared dim — the mechanism that keeps pgvector `vector(768)`
3752    /// fleet schemas + ANN indexes (≤2000-dim limit) usable with
3753    /// high-dim API models. `None` = model-native dim.
3754    pub requested_dim: Option<u32>,
3755    /// #1598 — resolved embedding API key. `None` for
3756    /// `backend = "ollama"` (no auth) and for keyless self-hosted
3757    /// OpenAI-compatible endpoints. Private — access via
3758    /// [`Self::api_key`].
3759    api_key: Option<String>,
3760    /// #1598 — provenance of the resolved API key for boot-banner /
3761    /// doctor-probe display.
3762    pub key_source: KeySource,
3763    /// Provenance of the resolved configuration.
3764    pub source: ConfigSource,
3765}
3766
3767impl ResolvedEmbeddings {
3768    /// Access the resolved embedding API key. Use this only when
3769    /// constructing the embed client; do NOT log or `{:?}` the result.
3770    #[must_use]
3771    pub fn api_key(&self) -> Option<&str> {
3772        self.api_key.as_deref()
3773    }
3774
3775    /// #1598 — construct from explicit parts. Prefer
3776    /// [`AppConfig::resolve_embeddings`]; this exists for tests and
3777    /// sibling surfaces (e.g. the reembed CLI) that synthesise a
3778    /// resolved view without an `AppConfig`.
3779    #[must_use]
3780    pub fn from_parts(
3781        backend: String,
3782        url: String,
3783        model: String,
3784        embedding_dim: Option<u32>,
3785        api_key: Option<String>,
3786    ) -> Self {
3787        Self {
3788            backend,
3789            url,
3790            model,
3791            backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
3792            embedding_dim,
3793            requested_dim: None,
3794            api_key,
3795            key_source: KeySource::None,
3796            source: ConfigSource::CompiledDefault,
3797        }
3798    }
3799
3800    /// #1598 (fleet follow-up) — builder for the explicit requested
3801    /// output dimensionality (see [`Self::requested_dim`]).
3802    #[must_use]
3803    pub fn with_requested_dim(mut self, dim: Option<u32>) -> Self {
3804        self.requested_dim = dim;
3805        self
3806    }
3807}
3808
3809impl std::fmt::Debug for ResolvedEmbeddings {
3810    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3811        f.debug_struct("ResolvedEmbeddings")
3812            .field("backend", &self.backend)
3813            .field("url", &self.url)
3814            .field("model", &self.model)
3815            .field("backfill_batch", &self.backfill_batch)
3816            .field(
3817                crate::models::field_names::EMBEDDING_DIM,
3818                &self.embedding_dim,
3819            )
3820            .field("requested_dim", &self.requested_dim)
3821            .field(
3822                "api_key",
3823                &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3824            )
3825            .field("key_source", &self.key_source)
3826            .field("source", &self.source)
3827            .finish()
3828    }
3829}
3830
3831/// Canonical resolved-reranker configuration. Produced by
3832/// [`AppConfig::resolve_reranker`].
3833#[derive(Debug, Clone, PartialEq, Eq)]
3834pub struct ResolvedReranker {
3835    /// Whether the cross-encoder rerank stage runs.
3836    pub enabled: bool,
3837    /// Cross-encoder model identifier.
3838    pub model: String,
3839    /// #1604 — tokenized length cap for rerank inputs, resolved via
3840    /// `AI_MEMORY_RERANK_MAX_SEQ` env > `[reranker].max_seq_tokens` >
3841    /// `crate::reranker::RERANK_MAX_SEQ_DEFAULT`. Seeded into
3842    /// `crate::reranker::set_rerank_max_seq` at boot.
3843    pub max_seq_tokens: usize,
3844    /// Provenance of the resolved configuration.
3845    pub source: ConfigSource,
3846}
3847
3848/// Canonical resolved-storage configuration. Produced by
3849/// [`AppConfig::resolve_storage`].
3850#[derive(Debug, Clone, PartialEq, Eq)]
3851pub struct ResolvedStorage {
3852    /// Default namespace for new memories when the caller omits one.
3853    pub default_namespace: String,
3854    /// Whether to archive memories before GC deletion.
3855    pub archive_on_gc: bool,
3856    /// Archive retention ceiling in days (`None` = disabled).
3857    pub archive_max_days: Option<i64>,
3858    /// Memory budget in MB for the auto tier selector.
3859    pub max_memory_mb: Option<usize>,
3860    /// #1579 B7 — resolved sqlite `PRAGMA mmap_size` in bytes
3861    /// (`AI_MEMORY_DB_MMAP_SIZE` env > `[storage].db_mmap_size_bytes`
3862    /// > compiled 256 MiB default). `0` disables memory-mapped I/O.
3863    /// Seeded into `crate::storage::set_db_mmap_size` at boot.
3864    pub db_mmap_size_bytes: i64,
3865    /// #1590 — per-field provenance of `default_namespace`:
3866    /// [`ConfigSource::Config`] when `[storage].default_namespace` is
3867    /// explicitly set, [`ConfigSource::Legacy`] when only the
3868    /// deprecated flat `default_namespace` field is set, else
3869    /// [`ConfigSource::CompiledDefault`]. The section-level `source`
3870    /// tag below cannot express this — it reports `Config` whenever a
3871    /// `[storage]` section EXISTS even if `default_namespace` itself
3872    /// was never configured, and the write-path defaulting must only
3873    /// be overridden by an explicit operator choice (unconfigured
3874    /// deployments keep the historical per-surface ladders).
3875    pub default_namespace_source: ConfigSource,
3876    /// Provenance of the resolved configuration.
3877    pub source: ConfigSource,
3878}
3879
3880impl ResolvedStorage {
3881    /// #1590 — the operator-EXPLICITLY-configured default namespace,
3882    /// or `None` when `default_namespace` merely bottomed out at the
3883    /// compiled `"global"` default. Write-path consumers (MCP
3884    /// `memory_store`, HTTP `POST /api/v1/memories`, the CLI
3885    /// namespace ladder) only override their historical defaults when
3886    /// this returns `Some`.
3887    #[must_use]
3888    pub fn explicit_default_namespace(&self) -> Option<&str> {
3889        if self.default_namespace_source == ConfigSource::CompiledDefault {
3890            None
3891        } else {
3892            Some(self.default_namespace.as_str())
3893        }
3894    }
3895}
3896
3897// ---------------------------------------------------------------------------
3898// #1590 — process-wide operator-configured default namespace
3899// ---------------------------------------------------------------------------
3900
3901/// #1590 — process-wide operator-configured default namespace, seeded
3902/// once at boot by `crate::daemon_runtime::run` from
3903/// [`ResolvedStorage::explicit_default_namespace`]. `None` (the
3904/// unseeded / unconfigured state) preserves every surface's historical
3905/// default: MCP + HTTP store fall back to [`crate::DEFAULT_NAMESPACE`]
3906/// and the CLI falls back to its git-remote → cwd-basename → global
3907/// inference ladder. Mirrors the `crate::quotas::QuotaDefaults` /
3908/// `crate::storage::set_db_mmap_size` boot-seeding pattern for knobs
3909/// consumed where no `AppConfig` is in scope (serde default fns, MCP
3910/// param parsing, CLI helpers).
3911static CONFIGURED_DEFAULT_NAMESPACE: std::sync::RwLock<Option<String>> =
3912    std::sync::RwLock::new(None);
3913
3914/// #1590 — seed (or clear) the process-wide operator-configured
3915/// default namespace. Called once at boot; pass `None` for
3916/// deployments without an explicit `[storage].default_namespace`.
3917pub fn set_configured_default_namespace(namespace: Option<String>) {
3918    let mut slot = CONFIGURED_DEFAULT_NAMESPACE
3919        .write()
3920        .unwrap_or_else(std::sync::PoisonError::into_inner);
3921    *slot = namespace.filter(|s| !s.trim().is_empty());
3922}
3923
3924/// #1590 — the operator-configured default namespace, or `None` when
3925/// the operator never explicitly configured one (callers then apply
3926/// their historical per-surface default).
3927#[must_use]
3928pub fn configured_default_namespace() -> Option<String> {
3929    CONFIGURED_DEFAULT_NAMESPACE
3930        .read()
3931        .unwrap_or_else(std::sync::PoisonError::into_inner)
3932        .clone()
3933}
3934
3935/// Test-only gate serialising mutations of the process-wide
3936/// [`CONFIGURED_DEFAULT_NAMESPACE`] slot (same pattern as
3937/// [`lock_permissions_mode_for_test`]). Every test that seeds the slot
3938/// — or asserts the unseeded default — takes this guard first so
3939/// parallel tests cannot observe each other's transient state.
3940pub fn lock_configured_default_namespace_for_test() -> std::sync::MutexGuard<'static, ()> {
3941    static GATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3942    GATE_LOCK
3943        .lock()
3944        .unwrap_or_else(std::sync::PoisonError::into_inner)
3945}
3946
3947/// Canonical resolved operator-tunable capacity limits. Produced by
3948/// [`AppConfig::resolve_limits`]. Consumed at daemon boot to install the
3949/// quota-row auto-insert defaults (`crate::quotas::set_quota_defaults`)
3950/// and the HTTP list/bulk page-size cap (`AppState::max_page_size`).
3951#[derive(Debug, Clone, PartialEq, Eq)]
3952pub struct ResolvedLimits {
3953    /// Per-(agent, namespace) daily memory-write ceiling.
3954    pub max_memories_per_day: i64,
3955    /// Per-(agent, namespace) lifetime storage cap in bytes.
3956    pub max_storage_bytes: i64,
3957    /// Per-(agent, namespace) daily link-creation ceiling.
3958    pub max_links_per_day: i64,
3959    /// Maximum items per list response / bulk-or-sync request.
3960    pub max_page_size: usize,
3961    /// Provenance of the resolved configuration.
3962    pub source: ConfigSource,
3963}
3964
3965/// Env override for `[limits].max_memories_per_day`.
3966pub const ENV_MAX_MEMORIES_PER_DAY: &str = "AI_MEMORY_MAX_MEMORIES_PER_DAY";
3967/// Env override for `[limits].max_storage_bytes`.
3968pub const ENV_MAX_STORAGE_BYTES: &str = "AI_MEMORY_MAX_STORAGE_BYTES";
3969/// Env override for `[limits].max_links_per_day`.
3970pub const ENV_MAX_LINKS_PER_DAY: &str = "AI_MEMORY_MAX_LINKS_PER_DAY";
3971/// Env override for `[limits].max_page_size`.
3972pub const ENV_MAX_PAGE_SIZE: &str = "AI_MEMORY_MAX_PAGE_SIZE";
3973
3974/// #1579 B7 — env override for the sqlite `PRAGMA mmap_size`
3975/// (`[storage].db_mmap_size_bytes`), in whole bytes. `0` disables
3976/// memory-mapped I/O; negative / unparseable values fall through to
3977/// the `[storage]` section, then to the compiled 256 MiB default
3978/// (`crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES`).
3979pub const ENV_DB_MMAP_SIZE: &str = "AI_MEMORY_DB_MMAP_SIZE";
3980
3981/// #1604 — env override for the tokenized length of rerank inputs
3982/// (`[reranker].max_seq_tokens`), in tokens. Values that are zero,
3983/// unparseable, or above the model ceiling
3984/// (`crate::reranker::CROSS_ENCODER_MAX_SEQ`) fall through to the
3985/// `[reranker]` section, then to the compiled default
3986/// (`crate::reranker::RERANK_MAX_SEQ_DEFAULT`).
3987pub const ENV_RERANK_MAX_SEQ: &str = "AI_MEMORY_RERANK_MAX_SEQ";
3988
3989/// v0.7.0 (a) — env override for the postgres pool ceiling
3990/// (`postgres_pool_max_connections`). Byte-matches the name documented
3991/// in `docs/enterprise-deployment.md §5.6`.
3992pub const ENV_PG_POOL_MAX: &str = "AI_MEMORY_PG_POOL_MAX";
3993/// v0.7.0 (a) — env override for the postgres pool floor
3994/// (`postgres_pool_min_connections`). Byte-matches the name documented
3995/// in `docs/enterprise-deployment.md §5.6`.
3996pub const ENV_PG_POOL_MIN: &str = "AI_MEMORY_PG_POOL_MIN";
3997/// v0.7.0 (a) — env override for the pool acquire-timeout
3998/// (`postgres_acquire_timeout_secs`), in whole seconds.
3999pub const ENV_PG_ACQUIRE_TIMEOUT_SECS: &str = "AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS";
4000
4001/// #1067 — env carrying the LLM Bearer-auth secret; highest-precedence
4002/// layer of the `[llm]` API-key resolution ladder ([`KeySource`]).
4003pub const ENV_LLM_API_KEY: &str = "AI_MEMORY_LLM_API_KEY";
4004
4005/// #1598 — env override for the embedding backend selector
4006/// (`[embeddings].backend`). Same accepted values as the section
4007/// field: `ollama`, any #1067 alias, or `openai-compatible`.
4008pub const ENV_EMBED_BACKEND: &str = "AI_MEMORY_EMBED_BACKEND";
4009/// #1598 — env override for the embedding endpoint base URL
4010/// (`[embeddings].base_url` / `[embeddings].url`).
4011pub const ENV_EMBED_BASE_URL: &str = "AI_MEMORY_EMBED_BASE_URL";
4012/// #1598 — env override for the embedding model id
4013/// (`[embeddings].model`).
4014pub const ENV_EMBED_MODEL: &str = "AI_MEMORY_EMBED_MODEL";
4015/// #1598 — env carrying the embedding Bearer-auth secret;
4016/// highest-precedence layer of the `[embeddings]` API-key resolution
4017/// ladder (mirrors [`ENV_LLM_API_KEY`]).
4018pub const ENV_EMBED_API_KEY: &str = "AI_MEMORY_EMBED_API_KEY";
4019/// #38 — env override for the embedding backfill batch size
4020/// (`[embeddings].backfill_batch`). Hoisted from a raw literal in the
4021/// resolver per the no-hardcoded-literals discipline (#1598).
4022pub const ENV_EMBED_BACKFILL_BATCH: &str = "AI_MEMORY_EMBED_BACKFILL_BATCH";
4023
4024/// Compiled-default embedding model id (the v0.7.0 autonomous-tier
4025/// nomic default), shared by the resolver and its precedence tests.
4026pub(crate) const DEFAULT_EMBED_MODEL: &str = "nomic-embed-text-v1.5";
4027/// Compiled-default embedding backfill batch size.
4028pub(crate) const DEFAULT_EMBED_BACKFILL_BATCH: u32 = 100;
4029
4030/// v0.7.x (issue #1168) — bundle the three model-resolver outputs into
4031/// a single triple consumed by the capabilities surface. Lets callers
4032/// thread ONE struct through `handle_capabilities_with_conn` /
4033/// `handle_capabilities_with_conn_v3` / `build_capabilities_overlay`
4034/// instead of three independent borrows, and makes the contract loud:
4035/// `memory_capabilities.models.*` reflects the operator-resolved
4036/// configuration, NEVER the compiled tier preset.
4037///
4038/// **Production constructor:** [`AppConfig::resolve_models`].
4039/// **Test / back-compat constructor:** [`ResolvedModels::from_tier_preset`].
4040#[derive(Debug, Clone, PartialEq, Eq)]
4041pub struct ResolvedModels {
4042    /// Resolved LLM configuration (`AppConfig::resolve_llm`).
4043    pub llm: ResolvedLlm,
4044    /// Resolved embedder configuration (`AppConfig::resolve_embeddings`).
4045    pub embeddings: ResolvedEmbeddings,
4046    /// Resolved reranker configuration (`AppConfig::resolve_reranker`).
4047    pub reranker: ResolvedReranker,
4048}
4049
4050/// Compiled-default `ResolvedModels` triple. Equivalent to running
4051/// the resolvers against an [`AppConfig::default`] — Ollama backend,
4052/// no operator overrides, no API key, reranker disabled. Convenient
4053/// for test scaffolds that need a `ResolvedModels` value but don't
4054/// care about its contents.
4055impl Default for ResolvedModels {
4056    fn default() -> Self {
4057        Self {
4058            llm: ResolvedLlm {
4059                backend: "ollama".to_string(),
4060                model: String::new(),
4061                base_url: "http://localhost:11434".to_string(),
4062                api_key: None,
4063                api_key_source: KeySource::None,
4064                source: ConfigSource::CompiledDefault,
4065            },
4066            embeddings: ResolvedEmbeddings {
4067                backend: "ollama".to_string(),
4068                url: "http://localhost:11434".to_string(),
4069                model: String::new(),
4070                backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
4071                embedding_dim: None,
4072                requested_dim: None,
4073                api_key: None,
4074                key_source: KeySource::None,
4075                source: ConfigSource::CompiledDefault,
4076            },
4077            reranker: ResolvedReranker {
4078                enabled: false,
4079                model: "ms-marco-MiniLM-L-6-v2".to_string(),
4080                max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
4081                source: ConfigSource::CompiledDefault,
4082            },
4083        }
4084    }
4085}
4086
4087impl ResolvedModels {
4088    /// Back-compat constructor: synthesise a `ResolvedModels` triple
4089    /// from the compiled [`TierConfig`] preset alone.
4090    ///
4091    /// Yields the same [`CapabilityModels`] byte-for-byte that the
4092    /// pre-#1168 `TierConfig::capabilities()` produced, so legacy
4093    /// callers + tests that scaffold a `TierConfig` in isolation (no
4094    /// `AppConfig` available) continue to assert their original
4095    /// strings. The synthesised triple carries
4096    /// [`ConfigSource::CompiledDefault`] on every leaf so observers can
4097    /// distinguish a back-compat scaffold from an operator-resolved
4098    /// production triple.
4099    ///
4100    /// **Production paths** that have access to the operator
4101    /// [`AppConfig`] MUST use [`AppConfig::resolve_models`] instead.
4102    /// Using this helper in a production wrapper re-introduces the
4103    /// #1168 drift (the capabilities surface would report the tier
4104    /// preset instead of the operator-configured backend / model).
4105    #[must_use]
4106    pub fn from_tier_preset(tier: &TierConfig) -> Self {
4107        Self {
4108            llm: ResolvedLlm {
4109                backend: "ollama".to_string(),
4110                model: tier.llm_model.clone().unwrap_or_default(),
4111                base_url: "http://localhost:11434".to_string(),
4112                api_key: None,
4113                api_key_source: KeySource::None,
4114                source: ConfigSource::CompiledDefault,
4115            },
4116            embeddings: ResolvedEmbeddings {
4117                backend: "ollama".to_string(),
4118                url: "http://localhost:11434".to_string(),
4119                model: tier
4120                    .embedding_model
4121                    .map(|m| m.hf_model_id().to_string())
4122                    .unwrap_or_default(),
4123                backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
4124                // v0.7.x (#1169) — back-compat constructor: source the
4125                // dim from the tier-preset enum directly so the
4126                // ResolvedModels::from_tier_preset path matches the
4127                // pre-#1169 capabilities byte-shape (the test invariant
4128                // pinned by tests/issue_1168_*::from_tier_preset_*).
4129                embedding_dim: tier.embedding_model.map(|m| m.dim() as u32),
4130                requested_dim: None,
4131                api_key: None,
4132                key_source: KeySource::None,
4133                source: ConfigSource::CompiledDefault,
4134            },
4135            reranker: ResolvedReranker {
4136                enabled: tier.cross_encoder,
4137                // Back-compat: the pre-#1168 capabilities surface emitted
4138                // the full `cross-encoder/...` HF org-prefixed string when
4139                // the tier-preset enabled the cross-encoder. Preserve
4140                // that here so legacy assertions stay byte-equal.
4141                model: "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string(),
4142                max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
4143                source: ConfigSource::CompiledDefault,
4144            },
4145        }
4146    }
4147}
4148
4149/// v0.7.0 (issue #518) — `[agents]` top-level block. Today only carries
4150/// the `defaults` sub-block (`[agents.defaults.recall_scope]`); future
4151/// agent-scoped knobs (per-agent quota overrides, per-agent autonomy
4152/// hook policy) can stack here without bloating the top-level
4153/// `AppConfig` surface.
4154///
4155/// Wire format:
4156/// ```toml
4157/// [agents.defaults.recall_scope]
4158/// namespaces = ["projects/atlas"]
4159/// since = "24h"
4160/// tier = "long"
4161/// limit = 50
4162/// ```
4163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4164pub struct AgentsConfig {
4165    /// `[agents.defaults]` sub-block. `None` keeps recall semantics
4166    /// exactly as v0.6.x — every cross-session `memory_recall` requires
4167    /// explicit filters. `Some` enables `session_default=true` callers
4168    /// to splice these defaults into their request before storage
4169    /// dispatch.
4170    #[serde(default)]
4171    pub defaults: Option<AgentDefaults>,
4172}
4173
4174/// v0.7.0 (issue #518) — `[agents.defaults]` sub-block. Today exposes a
4175/// single field: `recall_scope`. Future expansion (per-call timeouts,
4176/// per-call tag filters, …) lives here.
4177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4178pub struct AgentDefaults {
4179    /// `[agents.defaults.recall_scope]` — default filter set spliced
4180    /// into recall calls that pass `session_default=true` and omit
4181    /// individual filter fields. See [`RecallScope`] for field
4182    /// semantics. `None` is equivalent to "no defaults configured".
4183    #[serde(default)]
4184    pub recall_scope: Option<RecallScope>,
4185}
4186
4187/// v0.7.0 (issue #518) — operator-configured recall defaults. Each
4188/// field is optional; when present and the inbound recall request
4189/// omits the corresponding axis AND passes `session_default=true`, the
4190/// handler splices in the configured value before dispatching to the
4191/// storage layer.
4192///
4193/// Resolution: **explicit request args > recall_scope defaults >
4194/// compiled defaults**. The splice never overrides an explicit filter
4195/// — operators can always narrow the result set further at call time.
4196///
4197/// Wire format:
4198/// ```toml
4199/// [agents.defaults.recall_scope]
4200/// namespaces = ["projects/atlas"]   # default namespace filter
4201/// since = "24h"                     # duration → since = now() - 24h
4202/// tier = "long"                     # "short" / "mid" / "long"
4203/// limit = 50                        # default cap
4204/// ```
4205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4206pub struct RecallScope {
4207    /// Default namespace filter applied when the request omits its
4208    /// own `namespace` field. The current recall handlers accept a
4209    /// single namespace per call; when multiple namespaces are
4210    /// configured we apply the first one. (The list form is future-
4211    /// compatible with a planned multi-namespace recall surface.)
4212    #[serde(default)]
4213    pub namespaces: Option<Vec<String>>,
4214    /// Default time-window applied when the request omits `since`.
4215    /// Expressed as a duration string: `"24h"`, `"7d"`, `"30m"`, … See
4216    /// [`parse_duration_string`] for the parser. The handler resolves
4217    /// it to `now() - duration` at request time and passes the
4218    /// resulting RFC3339 timestamp through the existing `since`
4219    /// filter — no new SQL path.
4220    #[serde(default)]
4221    pub since: Option<String>,
4222    /// Default tier filter applied when the request omits its own
4223    /// `tier`. Accepted values: `"short"` / `"mid"` / `"long"`. The
4224    /// sqlite recall handlers do not currently expose a tier
4225    /// parameter, so this knob is applied on the postgres SAL path
4226    /// (which carries a `Filter.tier`) and stored on the request
4227    /// envelope for forward-compatibility on sqlite (no observable
4228    /// behaviour change there).
4229    #[serde(default)]
4230    pub tier: Option<String>,
4231    /// Default recall limit applied when the request omits its own
4232    /// `limit`. The handler still clamps to the per-tool maximum
4233    /// (50) after applying this default, so an oversized value here
4234    /// degrades gracefully.
4235    #[serde(default)]
4236    pub limit: Option<u32>,
4237}
4238
4239/// v0.7.0 Cluster G (#767) — `[confidence]` config block. Carries the
4240/// retention window for `confidence_shadow_observations` consumed by
4241/// the periodic GC sweep wired into `daemon_runtime::spawn_gc_loop`.
4242///
4243/// Wire format:
4244/// ```toml
4245/// [confidence]
4246/// shadow_retention_days = 30
4247/// ```
4248///
4249/// `None` → the compiled default
4250/// ([`crate::confidence::shadow::DEFAULT_SHADOW_RETENTION_DAYS`] = 30)
4251/// applies. Set to `0` or a negative value to disable the sweep
4252/// (matches the audit-honest "do-nothing-on-zero" convention used by
4253/// `archive_max_days`).
4254#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
4255pub struct ConfidenceConfig {
4256    /// Retention window (in days) for shadow-mode observation rows.
4257    /// Rows whose `observed_at` is older than `now - N days` are
4258    /// deleted by the GC sweep. `None` → compiled default of 30 days.
4259    /// `Some(0)` or `Some(<0)` → sweep is a no-op (operator opt-out
4260    /// for compliance / forensic-retention scenarios).
4261    pub shadow_retention_days: Option<i64>,
4262}
4263
4264impl ConfidenceConfig {
4265    /// Effective retention window, honoring the compiled default when
4266    /// the config block is absent or `shadow_retention_days` is unset.
4267    #[must_use]
4268    pub fn effective_shadow_retention_days(&self) -> i64 {
4269        self.shadow_retention_days
4270            .unwrap_or(crate::confidence::shadow::DEFAULT_SHADOW_RETENTION_DAYS)
4271    }
4272}
4273
4274/// v0.7.0 H7 (round-2) — compiled default per-request HTTP timeout.
4275/// Applied when `AppConfig::request_timeout_secs` is `None`.
4276pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
4277
4278/// v0.7.0 H8 (round-2) — compiled default per-LLM-call timeout.
4279/// Applied when `AppConfig::llm_call_timeout_secs` is `None`.
4280pub const DEFAULT_LLM_CALL_TIMEOUT_SECS: u64 = 30;
4281
4282// ---------------------------------------------------------------------------
4283// Hooks / subscription HMAC (K7)
4284// ---------------------------------------------------------------------------
4285
4286/// `[hooks]` config block. v0.7.0 K7 — operator-facing knobs for the
4287/// outgoing-webhook surface.
4288///
4289/// Wire format:
4290/// ```toml
4291/// [hooks.subscription]
4292/// hmac_secret = "<plaintext-secret>"
4293/// ```
4294///
4295/// When `hmac_secret` is set, EVERY outbound webhook payload is signed
4296/// with `HMAC-SHA256(hmac_secret, "<timestamp>.<body>")` and the hex
4297/// digest is sent as the `X-AI-Memory-Signature: sha256=<hex>` header.
4298/// The override applies even to subscriptions that did not register a
4299/// per-subscription secret. When both are set, the per-subscription
4300/// secret wins (subscription-scoped trust beats server-scoped trust).
4301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4302pub struct HooksConfig {
4303    /// `[hooks.subscription]` sub-block. Optional — when omitted, no
4304    /// server-wide HMAC override applies.
4305    pub subscription: Option<HooksSubscriptionConfig>,
4306}
4307
4308/// `[hooks.subscription]` sub-block. K7 ships one knob today
4309/// (`hmac_secret`); future K-track work may add per-event opt-out
4310/// filters or alternate signing algorithms.
4311///
4312/// #1262 — `Debug` is implemented manually to redact `hmac_secret` so
4313/// accidental `{:?}` prints never leak the signing key. #1258 — the
4314/// manual `Drop` impl zeroizes the secret on scope exit.
4315#[derive(Clone, Default, Serialize, Deserialize)]
4316pub struct HooksSubscriptionConfig {
4317    /// Server-wide HMAC secret. Plaintext on disk — operators are
4318    /// expected to chmod 600 the config file (same posture as the
4319    /// existing `api_key` field).
4320    ///
4321    /// #1262 — `skip_serializing` blocks the secret from being echoed
4322    /// through any `serde_json::to_string(&HooksSubscriptionConfig)`
4323    /// path.
4324    #[serde(default, skip_serializing)]
4325    pub hmac_secret: Option<String>,
4326}
4327
4328impl std::fmt::Debug for HooksSubscriptionConfig {
4329    /// #1262 — redact `hmac_secret` to `<redacted>` when present.
4330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4331        f.debug_struct("HooksSubscriptionConfig")
4332            .field(
4333                "hmac_secret",
4334                &self
4335                    .hmac_secret
4336                    .as_ref()
4337                    .map(|_| crate::REDACTED_PLACEHOLDER),
4338            )
4339            .finish()
4340    }
4341}
4342
4343impl HooksSubscriptionConfig {
4344    /// #1258 — zeroize the `hmac_secret` buffer in place. Idempotent.
4345    /// The `Drop` impl below delegates here so the helper is the
4346    /// single source of truth for the zero-on-secret-loss contract.
4347    /// Tests probe the buffer via this entry point so they observe
4348    /// the post-zeroize state of a still-live allocation (probing
4349    /// after the owning value is dropped is UB — the allocator's
4350    /// free-list bookkeeping stamps the first 8-16 bytes of the
4351    /// just-freed slot and that's not a `zeroize` defect; see #1321).
4352    pub fn zeroize_secrets(&mut self) {
4353        if let Some(secret) = self.hmac_secret.as_mut() {
4354            use zeroize::Zeroize;
4355            secret.zeroize();
4356        }
4357    }
4358}
4359
4360impl Drop for HooksSubscriptionConfig {
4361    /// #1258 — zeroize `hmac_secret` on scope exit. Delegates to
4362    /// [`HooksSubscriptionConfig::zeroize_secrets`].
4363    fn drop(&mut self) {
4364        self.zeroize_secrets();
4365    }
4366}
4367
4368/// v0.7.0 H5 (round-2) — `[verify]` config block. Operator-facing
4369/// knobs for `POST /api/v1/links/verify`. Today exposes one knob:
4370/// `require_nonce` (default `false`).
4371///
4372/// Wire format:
4373/// ```toml
4374/// [verify]
4375/// require_nonce = true     # strict mode — every verify request
4376///                          # must carry verification_nonce
4377/// ```
4378///
4379/// When `require_nonce = false` (the default), the handler logs a
4380/// deprecation WARN when a request omits `verification_nonce` but
4381/// still allows it through. When `true`, missing nonces are rejected
4382/// with 409 Conflict and the operator's audit trail receives every
4383/// attempted reuse.
4384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4385pub struct VerifyConfig {
4386    /// When `true`, `POST /api/v1/links/verify` requires every
4387    /// request body to include a `verification_nonce` field. Missing
4388    /// or empty nonces produce a 400 Bad Request. Already-seen
4389    /// `(link_id, signature, nonce)` tuples produce a 409 Conflict
4390    /// with `{"error":"verification replay detected"}`. Default `false`
4391    /// preserves the v0.6.x verify-anytime semantics; operators
4392    /// opting into the H5 replay-protection guarantee set this to
4393    /// `true` after their clients have been updated to emit nonces.
4394    #[serde(default)]
4395    pub require_nonce: bool,
4396}
4397
4398/// v0.7.0 H11 (#628 blocker) — `[subscriptions]` block. Operator
4399/// knobs for the outgoing-webhook surface that are NOT specific to
4400/// HMAC signing (which lives under `[hooks.subscription]`).
4401///
4402/// Wire format:
4403/// ```toml
4404/// [subscriptions]
4405/// allow_loopback_webhooks = true   # default false; opt-in for testing
4406/// ```
4407///
4408/// When unset (or false), the SSRF guard rejects webhook URLs that
4409/// resolve to loopback addresses (`127.0.0.0/8`, `localhost`, `::1`).
4410/// Loopback hosts are reachable from the daemon process itself, so
4411/// permitting them by default exposes any locally-bound service
4412/// (database, internal admin sockets) to authenticated SSRF.
4413#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4414pub struct SubscriptionsConfig {
4415    /// Re-enable loopback webhook URLs. Default `false` (loopback
4416    /// rejected). Operators who need to point a webhook at a local
4417    /// listener (CI, dev) set this to `true` explicitly.
4418    #[serde(default)]
4419    pub allow_loopback_webhooks: bool,
4420}
4421
4422impl AppConfig {
4423    /// v0.7.0 K7 — resolved server-wide webhook HMAC secret. `None`
4424    /// means no server-wide override (per-subscription secrets still
4425    /// apply via the legacy code path).
4426    #[must_use]
4427    pub fn effective_hooks_hmac_secret(&self) -> Option<String> {
4428        self.hooks
4429            .as_ref()
4430            .and_then(|h| h.subscription.as_ref())
4431            .and_then(|s| s.hmac_secret.clone())
4432    }
4433
4434    /// v0.7.0 (issue #518) — resolved `[agents.defaults.recall_scope]`
4435    /// block. Returns `Some(&scope)` when configured, `None` otherwise.
4436    /// Consumed by the recall handlers (sqlite + postgres SAL branches,
4437    /// MCP `handle_recall`, CLI `cmd_recall`) to splice defaults into
4438    /// requests that pass `session_default=true` and omit one or more
4439    /// filter fields.
4440    #[must_use]
4441    pub fn effective_recall_scope(&self) -> Option<&RecallScope> {
4442        self.agents
4443            .as_ref()
4444            .and_then(|a| a.defaults.as_ref())
4445            .and_then(|d| d.recall_scope.as_ref())
4446    }
4447
4448    /// v0.7.0 H11 (#628 blocker) — resolved loopback-webhook opt-in
4449    /// flag. Defaults to `false` (loopback rejected — closes the
4450    /// authenticated SSRF gadget against local services). Operators
4451    /// who need loopback for testing set
4452    /// `[subscriptions] allow_loopback_webhooks = true`.
4453    ///
4454    /// Resolution order (mirrors `effective_permissions_mode`):
4455    /// 1. `AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS` env var (`1` / `true` —
4456    ///    case-insensitive). Lets the integration suite — which
4457    ///    sets `AI_MEMORY_NO_CONFIG=1` and therefore cannot use
4458    ///    `[subscriptions]` from `config.toml` — bind wiremock at
4459    ///    `127.0.0.1:0` and drive webhooks through it without
4460    ///    touching the production default.
4461    /// 2. `[subscriptions].allow_loopback_webhooks` from `config.toml`.
4462    /// 3. Compiled default (`false` — loopback rejected).
4463    #[must_use]
4464    pub fn effective_allow_loopback_webhooks(&self) -> bool {
4465        if let Ok(raw) = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS") {
4466            match raw.to_ascii_lowercase().as_str() {
4467                "1" | "true" | "yes" | "on" => return true,
4468                "0" | "false" | "no" | "off" | "" => return false,
4469                other => {
4470                    eprintln!(
4471                        "ai-memory: AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS={other:?} is not a valid \
4472                         boolean (expected 1/true/yes/on or 0/false/no/off); falling back to \
4473                         config.toml"
4474                    );
4475                }
4476            }
4477        }
4478        self.subscriptions
4479            .as_ref()
4480            .is_some_and(|s| s.allow_loopback_webhooks)
4481    }
4482}
4483
4484// ---------------------------------------------------------------------------
4485// Process-wide handle for the K7 server-wide HMAC override.
4486// Mirrors the `ACTIVE_PERMISSIONS_MODE` pattern: set once at boot,
4487// read by `subscriptions::dispatch_event_with_details` without an
4488// API churn through every callsite.
4489//
4490// v0.7.x (issue #1174 follow-up #1192) — storage moved to
4491// `RuntimeContext::hooks_hmac_secret` so the HTTP daemon, the MCP
4492// stdio binary, and the CLI all share one source of truth. The
4493// accessors below delegate to the process-wide singleton; the wire
4494// semantics + the K7 integration-test fixture (which flips the value
4495// mid-process) are byte-equivalent.
4496// ---------------------------------------------------------------------------
4497
4498/// v0.7.0 K7 — set the process-wide webhook HMAC override. Called from
4499/// `main`/daemon bootstrap with the value from
4500/// `[hooks.subscription] hmac_secret`. Last writer wins — this is
4501/// production-safe because boot only invokes it once; tests use the
4502/// same setter to flip mid-process.
4503///
4504/// v0.7.x (issue #1192) — delegates to
4505/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4506/// lives on `RuntimeContext::hooks_hmac_secret`.
4507pub fn set_active_hooks_hmac_secret(secret: Option<String>) {
4508    if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
4509        .hooks_hmac_secret
4510        .write()
4511    {
4512        *w = secret;
4513    }
4514}
4515
4516/// v0.7.0 K7 — read the process-wide webhook HMAC override. Returns
4517/// `None` when unset (the K6-and-earlier behaviour: only
4518/// per-subscription secrets sign outgoing payloads).
4519///
4520/// v0.7.x (issue #1192) — delegates to
4521/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4522/// lives on `RuntimeContext::hooks_hmac_secret`.
4523#[must_use]
4524pub fn active_hooks_hmac_secret() -> Option<String> {
4525    crate::runtime_context::RuntimeContext::global()
4526        .hooks_hmac_secret
4527        .read()
4528        .ok()
4529        .and_then(|g| g.clone())
4530}
4531
4532// ---------------------------------------------------------------------------
4533// I1 cap (#628 agent-3 follow-up) — process-wide transcript decompression cap
4534// ---------------------------------------------------------------------------
4535//
4536// `transcripts::fetch` consults this getter to decide the maximum
4537// number of bytes a single transcript may decompress to. Operators
4538// who legitimately store >16 MiB transcripts raise the cap explicitly
4539// via `[transcripts] max_decompressed_bytes = …`; default-on uses the
4540// compiled `MAX_DECOMPRESSED_BYTES` constant. The cap is per-call;
4541// concurrent fetches consume up to N × this value of transient memory.
4542//
4543// v0.7.x (issue #1174 follow-up #1192) — storage moved to
4544// `RuntimeContext::max_decompressed_bytes`. The accessors below
4545// delegate; the per-call cap semantics are byte-equivalent.
4546
4547/// Set the process-wide decompression cap. Boot reads
4548/// `[transcripts] max_decompressed_bytes` and calls this; tests flip
4549/// mid-process to exercise both branches.
4550///
4551/// v0.7.x (issue #1192) — delegates to
4552/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4553/// lives on `RuntimeContext::max_decompressed_bytes`.
4554pub fn set_active_max_decompressed_bytes(cap: Option<usize>) {
4555    if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
4556        .max_decompressed_bytes
4557        .write()
4558    {
4559        *w = cap;
4560    }
4561}
4562
4563/// Read the process-wide decompression cap, falling back to the
4564/// compiled default when unset.
4565///
4566/// v0.7.x (issue #1192) — delegates to
4567/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4568/// lives on `RuntimeContext::max_decompressed_bytes`.
4569#[must_use]
4570pub fn active_max_decompressed_bytes() -> usize {
4571    crate::runtime_context::RuntimeContext::global()
4572        .max_decompressed_bytes
4573        .read()
4574        .ok()
4575        .and_then(|g| *g)
4576        .unwrap_or(crate::transcripts::MAX_DECOMPRESSED_BYTES)
4577}
4578
4579// ---------------------------------------------------------------------------
4580// H11 — process-wide handle for the loopback-webhook opt-in
4581// ---------------------------------------------------------------------------
4582//
4583// `validate_url` in `subscriptions.rs` consults this handle to decide
4584// whether to accept loopback webhook destinations. Default-OFF closes
4585// the SSRF gadget; the boot code in `main` / daemon reads
4586// `[subscriptions] allow_loopback_webhooks` and sets the flag here.
4587
4588// Default-OFF in production builds so the SSRF guard rejects loopback
4589// without explicit opt-in. Defaults to `true` under `cfg(test)` so
4590// the existing test surface (which binds wiremock to `127.0.0.1:0`
4591// and drives validate_url/validate_url_dns through real loopback
4592// URLs) passes without 16-test fan-out modifications. The H11
4593// default-OFF behaviour is independently asserted via the
4594// `validate_url_with` / `validate_url_dns_check_addrs` inner helpers
4595// in `subscriptions.rs`, so flipping the test-build default here
4596// does NOT relax the H11 ship-gate test coverage.
4597static ALLOW_LOOPBACK_WEBHOOKS: std::sync::atomic::AtomicBool =
4598    std::sync::atomic::AtomicBool::new(cfg!(test));
4599
4600/// v0.7.0 H11 — set the process-wide loopback-webhook opt-in. Called
4601/// from boot with the value of `[subscriptions] allow_loopback_webhooks`.
4602/// Defaults to `false` (loopback rejected).
4603pub fn set_allow_loopback_webhooks(allow: bool) {
4604    ALLOW_LOOPBACK_WEBHOOKS.store(allow, std::sync::atomic::Ordering::SeqCst);
4605}
4606
4607/// v0.7.0 H11 — read the process-wide loopback-webhook opt-in.
4608/// Returns `false` when unset (the safe default — loopback URLs are
4609/// rejected by the SSRF guard).
4610#[must_use]
4611pub fn allow_loopback_webhooks() -> bool {
4612    ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst)
4613}
4614
4615// ---------------------------------------------------------------------------
4616// Permissions / governance gate (K3)
4617// ---------------------------------------------------------------------------
4618
4619/// Enforcement posture consulted by [`crate::db::enforce_governance`].
4620///
4621/// v0.7.0 K3 — closes the v0.6.3.1 honest-Capabilities-v2 disclosure
4622/// that `permissions.mode = "advisory"` was advertised but the gate
4623/// itself returned `Deny` / `Pending` regardless. The gate now actually
4624/// honors this knob.
4625///
4626/// Wire format on `config.toml`:
4627///
4628/// ```toml
4629/// [permissions]
4630/// mode = "advisory"   # or "enforce" / "off"
4631/// ```
4632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4633#[serde(rename_all = "lowercase")]
4634pub enum PermissionsMode {
4635    /// Block on policy violation. `Deny`/`Pending` decisions returned
4636    /// to the caller as-is. The strict, audit-ready posture.
4637    Enforce,
4638    /// Log a warning and allow the action. Governance metadata is
4639    /// recorded but does not block writes. Default for v0.7.0 to
4640    /// preserve the v0.6.x posture for upgrading operators.
4641    Advisory,
4642    /// Skip the gate entirely. No policy resolution, no log, no
4643    /// `pending_actions` row. Useful for benchmarking and temporary
4644    /// freeze-thaw incident response.
4645    Off,
4646}
4647
4648impl Default for PermissionsMode {
4649    fn default() -> Self {
4650        Self::Advisory
4651    }
4652}
4653
4654impl PermissionsMode {
4655    /// Lowercase wire string for capabilities + doctor surfaces.
4656    #[must_use]
4657    pub fn as_str(self) -> &'static str {
4658        match self {
4659            Self::Enforce => "enforce",
4660            Self::Advisory => "advisory",
4661            Self::Off => "off",
4662        }
4663    }
4664}
4665
4666/// `[permissions]` block in `config.toml`. Carries the gate's
4667/// enforcement posture and (v0.7.0 K9) the declarative rule list
4668/// the unified [`crate::permissions::Permissions::evaluate`]
4669/// pipeline consults before mode + hook fall-through.
4670///
4671/// Wire format (rules — K9):
4672///
4673/// ```toml
4674/// [permissions]
4675/// mode = "enforce"
4676///
4677/// [[permissions.rules]]
4678/// namespace_pattern = "secrets/*"
4679/// op               = "memory_store"
4680/// agent_pattern    = "ai:*"
4681/// decision         = "deny"
4682/// reason           = "ai agents may not write to secrets"
4683/// ```
4684///
4685/// Rules are deny-first and longest-pattern-wins; see
4686/// [`crate::permissions`] module docs for the full combination
4687/// rule.
4688#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4689pub struct PermissionsConfig {
4690    /// Enforcement mode. `None` when the operator declared a
4691    /// `[permissions]` block but omitted `mode = ` — this is the
4692    /// "partial config" case that B4 (S5-M3) closes: such a block
4693    /// MUST NOT silently fall back to the serde-derived
4694    /// `PermissionsMode::default` (`advisory`), because the v0.7.0
4695    /// secure default is `enforce`. The
4696    /// [`AppConfig::effective_permissions_mode`] resolver maps
4697    /// `Some(cfg { mode: None })` to the secure default + a
4698    /// migration warning, so an operator who half-typed
4699    /// `[permissions]` and forgot the mode line still ships
4700    /// `enforce`, not the v0.6.x advisory posture.
4701    ///
4702    /// Serializes as omitted when `None` so a round-tripped config
4703    /// without an explicit `mode` keeps the partial-config shape
4704    /// for the next loader.
4705    #[serde(default, skip_serializing_if = "Option::is_none")]
4706    pub mode: Option<PermissionsMode>,
4707    /// v0.7.0 K9 — declarative permission rules. Each entry is a
4708    /// `(namespace_pattern, op, agent_pattern, decision)` tuple
4709    /// consulted by [`crate::permissions::Permissions::evaluate`]
4710    /// before the mode default falls through. Defaults to empty
4711    /// (no declarative rules — pre-K9 behaviour: mode + hooks +
4712    /// existing governance gate decide everything).
4713    #[serde(default)]
4714    pub rules: Vec<crate::permissions::PermissionRule>,
4715}
4716
4717// ---------------------------------------------------------------------------
4718// Process-wide permissions-mode handle (K3)
4719// ---------------------------------------------------------------------------
4720//
4721// The gate (`db::enforce_governance`) needs to consult the active mode
4722// at decision time but lives in the `db` module, which has no handle on
4723// `AppConfig`. We hold the active mode in a single `RwLock<Option<…>>`
4724// set by `main` (and the daemon runtime) so the gate can read the mode
4725// without an API churn through every callsite. When the lock is unset
4726// — the case for unit and integration tests that drive
4727// `db::enforce_governance` directly without booting the daemon — the
4728// gate defaults to [`PermissionsMode::Advisory`] (the v0.7.0 K3
4729// secure-but-non-blocking posture). Tests opt into `Enforce` via the
4730// `set_active_permissions_mode` setter or the
4731// `override_active_permissions_mode_for_test` alias.
4732//
4733// **#1174 pm-v3.1 PR7 (this commit)**: collapsed the previous
4734// dual-source-of-truth (a `OnceLock<PermissionsMode>` for production +
4735// an `AtomicU8` test-only override that secretly took precedence over
4736// it) into a single `RwLock<Option<PermissionsMode>>`. The previous
4737// `OnceLock` shape blocked legitimate runtime reload paths — a SIGHUP
4738// handler that wanted to re-resolve `[permissions].mode` from
4739// `config.toml` and call `set_active_permissions_mode` again would
4740// silently no-op, leaving the gate on the boot-time value while every
4741// other resolver caught the new value. The new shape supports
4742// last-writer-wins so a future SIGHUP / `ai-memory reload` surface
4743// can refresh the mode without restart. The test-override semantics
4744// are preserved: tests still hold the
4745// [`lock_permissions_mode_for_test`] guard around their mutations and
4746// the public setter / overrider signatures are unchanged.
4747
4748static ACTIVE_PERMISSIONS_MODE: std::sync::RwLock<Option<PermissionsMode>> =
4749    std::sync::RwLock::new(None);
4750
4751/// Set the process-wide active [`PermissionsMode`]. Called from `main`
4752/// (CLI) and the daemon bootstrap path with the value resolved from
4753/// `[permissions].mode` in `config.toml`. Last-writer-wins so a future
4754/// SIGHUP / `ai-memory reload` surface can refresh the mode without
4755/// restart (#1174 PR7); the previous `OnceLock` shape made repeat
4756/// callers silently no-op.
4757pub fn set_active_permissions_mode(mode: PermissionsMode) {
4758    if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
4759        *w = Some(mode);
4760    }
4761}
4762
4763/// The pre-initialization fallback mode for [`active_permissions_mode`].
4764///
4765/// Every production entry point (CLI, MCP, HTTP `serve`) resolves the
4766/// real mode via [`AppConfig::effective_permissions_mode`] — whose
4767/// v0.7.0 secure default is [`PermissionsMode::Enforce`] — and installs
4768/// it via [`set_active_permissions_mode`] during boot, BEFORE any write
4769/// can reach the governance gate. This constant is therefore only ever
4770/// observed when the gate is consulted before boot ran (a library
4771/// embedding that never called the setter, or a unit test that does not
4772/// opt into a specific mode). It is held at `Advisory` to preserve the
4773/// historical pre-init behaviour the test suite relies on; the
4774/// [`active_permissions_mode`] reader emits a one-shot WARN when it has
4775/// to fall back to this value so the uninitialized-gate condition is
4776/// observable rather than silent.
4777const UNINITIALIZED_PERMISSIONS_MODE_FALLBACK: PermissionsMode = PermissionsMode::Advisory;
4778
4779/// Read the process-wide active [`PermissionsMode`] installed at boot by
4780/// [`set_active_permissions_mode`] (sourced from
4781/// [`AppConfig::effective_permissions_mode`], whose v0.7.0 secure
4782/// default is [`PermissionsMode::Enforce`]).
4783///
4784/// When the slot is unset — i.e. boot has NOT run — this returns
4785/// [`UNINITIALIZED_PERMISSIONS_MODE_FALLBACK`] and emits a one-shot
4786/// operator-visible WARN, because consulting the governance gate before
4787/// the mode is installed is a defense-in-depth gap: the gate would run
4788/// against the pre-init fallback rather than the operator's resolved
4789/// mode. In production this path is unreachable (boot always installs
4790/// the mode first); the WARN exists to surface a regression if that
4791/// ordering ever breaks.
4792///
4793/// Test note: the K1 ship-gate matrix asserts `Pending`/`Deny`
4794/// outcomes from `db::enforce_governance` and therefore opts into
4795/// `Enforce` via [`set_active_permissions_mode`] at the start of each
4796/// scenario.
4797#[must_use]
4798pub fn active_permissions_mode() -> PermissionsMode {
4799    match ACTIVE_PERMISSIONS_MODE.read().ok().and_then(|g| *g) {
4800        Some(mode) => mode,
4801        None => {
4802            static UNINIT_GATE_WARN_ONCE: std::sync::Once = std::sync::Once::new();
4803            UNINIT_GATE_WARN_ONCE.call_once(|| {
4804                tracing::warn!(
4805                    target: crate::governance::GOVERNANCE_TRACE_TARGET,
4806                    fallback = UNINITIALIZED_PERMISSIONS_MODE_FALLBACK.as_str(),
4807                    "permissions mode consulted before boot installed it; using the \
4808                     pre-init fallback. Production entry points install the resolved \
4809                     mode (secure default: enforce) during boot — if you see this in \
4810                     a running daemon, the boot ordering regressed."
4811                );
4812            });
4813            UNINITIALIZED_PERMISSIONS_MODE_FALLBACK
4814        }
4815    }
4816}
4817
4818/// Test-only override of the active mode. Production code MUST use
4819/// [`set_active_permissions_mode`]; this helper exists so the K3 test
4820/// matrix can flip mode mid-test without spinning up a fresh process.
4821///
4822/// **#1174 PR7**: with the dual-source-of-truth collapse the override
4823/// is now a thin alias around [`set_active_permissions_mode`]. The
4824/// two functions are wire-equivalent at every callsite. The alias is
4825/// kept (rather than renaming all test callers in one pass) because
4826/// the `_for_test` suffix at every callsite documents the intent —
4827/// "this is a test poking the global gate" — better than an
4828/// unsuffixed setter would.
4829#[doc(hidden)]
4830pub fn override_active_permissions_mode_for_test(mode: PermissionsMode) {
4831    set_active_permissions_mode(mode);
4832}
4833
4834/// Test-only: clear any test-override so subsequent tests start from
4835/// the unset state (the [`PermissionsMode::Advisory`] default).
4836///
4837/// **#1174 PR7**: previously this cleared the `OVERRIDE_PERMISSIONS_MODE`
4838/// atomic without touching the production-side `OnceLock`, which let
4839/// a test that called the production setter once leak its value into
4840/// the next test. With the single-source-of-truth collapse, clearing
4841/// resets the lone slot — subsequent reads see `Advisory` until the
4842/// next setter call, which is the documented contract.
4843#[doc(hidden)]
4844pub fn clear_permissions_mode_override_for_test() {
4845    if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
4846        *w = None;
4847    }
4848}
4849
4850/// Test-only: acquire the global gate-mode serialization lock.
4851///
4852/// The active [`PermissionsMode`] lives in a process-wide atomic so
4853/// the gate at `db::enforce_governance` can read it without an API
4854/// churn through every callsite. Multiple lib tests flip the mode
4855/// (the K3 mode-matrix file, the CLI / HTTP gate scenarios, the
4856/// capabilities zero-state round-trip) and `cargo test --lib` runs
4857/// them in parallel by default. Each scenario MUST hold this guard
4858/// for its duration so two scenarios cannot race the atomic. The
4859/// returned guard poisons-OK so one panicking scenario does not
4860/// chain-fail the rest.
4861#[doc(hidden)]
4862#[must_use]
4863pub fn lock_permissions_mode_for_test() -> std::sync::MutexGuard<'static, ()> {
4864    use std::sync::Mutex;
4865    static GATE_LOCK: Mutex<()> = Mutex::new(());
4866    GATE_LOCK
4867        .lock()
4868        .unwrap_or_else(std::sync::PoisonError::into_inner)
4869}
4870
4871// ---------------------------------------------------------------------------
4872// Decision counters per mode (K3 — surfaced by doctor + capabilities)
4873// ---------------------------------------------------------------------------
4874
4875use std::sync::atomic::{AtomicU64, Ordering};
4876
4877/// Per-process per-mode decision counters (#1174 pm-v3.1 PR7).
4878///
4879/// Previously three sibling `static AtomicU64` items
4880/// (`DECISIONS_ENFORCE`/`_ADVISORY`/`_OFF`). Folding them into a
4881/// single struct keeps the in-memory layout identical (`#[repr(C)]`
4882/// is unnecessary — Rust's default field order is fine for the
4883/// atomic-counters-as-observability use case) while ensuring that
4884/// adding a fourth mode in the future requires a single grep-friendly
4885/// edit instead of N parallel static declarations.
4886///
4887/// `Relaxed` ordering is preserved everywhere the original three
4888/// statics used it: the counters are observability, not load-bearing
4889/// for correctness, and the inter-mode read consistency that an
4890/// `SeqCst` snapshot would buy is not exercised by any current caller
4891/// (`ai-memory doctor` + capabilities both render the snapshot as
4892/// three independent integers).
4893struct DecisionCounters {
4894    enforce: AtomicU64,
4895    advisory: AtomicU64,
4896    off: AtomicU64,
4897}
4898
4899impl DecisionCounters {
4900    const fn new() -> Self {
4901        Self {
4902            enforce: AtomicU64::new(0),
4903            advisory: AtomicU64::new(0),
4904            off: AtomicU64::new(0),
4905        }
4906    }
4907
4908    fn counter_for(&self, mode: PermissionsMode) -> &AtomicU64 {
4909        match mode {
4910            PermissionsMode::Enforce => &self.enforce,
4911            PermissionsMode::Advisory => &self.advisory,
4912            PermissionsMode::Off => &self.off,
4913        }
4914    }
4915}
4916
4917static DECISION_COUNTERS: DecisionCounters = DecisionCounters::new();
4918
4919/// Snapshot of decision counts per mode since process start. Surfaced
4920/// by `ai-memory doctor` and the capabilities `permissions` block so
4921/// operators can verify the gate is wired and observe drift between
4922/// "policies advertised" and "policies enforced".
4923#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
4924pub struct PermissionsDecisionCounts {
4925    pub enforce: u64,
4926    pub advisory: u64,
4927    pub off: u64,
4928}
4929
4930/// Increment the decision counter for `mode`. Called by the gate on
4931/// every consult. `Relaxed` is fine: the counters are observability,
4932/// not load-bearing for correctness.
4933pub fn record_permissions_decision(mode: PermissionsMode) {
4934    DECISION_COUNTERS
4935        .counter_for(mode)
4936        .fetch_add(1, Ordering::Relaxed);
4937}
4938
4939/// Snapshot the current per-mode decision counts.
4940#[must_use]
4941pub fn permissions_decision_counts() -> PermissionsDecisionCounts {
4942    PermissionsDecisionCounts {
4943        enforce: DECISION_COUNTERS.enforce.load(Ordering::Relaxed),
4944        advisory: DECISION_COUNTERS.advisory.load(Ordering::Relaxed),
4945        off: DECISION_COUNTERS.off.load(Ordering::Relaxed),
4946    }
4947}
4948
4949/// Test-only: zero the counters between scenarios so the K3 matrix
4950/// can assert exact deltas.
4951#[doc(hidden)]
4952pub fn reset_permissions_decision_counts_for_test() {
4953    DECISION_COUNTERS.enforce.store(0, Ordering::SeqCst);
4954    DECISION_COUNTERS.advisory.store(0, Ordering::SeqCst);
4955    DECISION_COUNTERS.off.store(0, Ordering::SeqCst);
4956}
4957
4958// ---------------------------------------------------------------------------
4959// Logging facility (PR-5)
4960// ---------------------------------------------------------------------------
4961
4962/// `[logging]` block in `config.toml`. Every field is `Option`; missing
4963/// fields fall back to the documented defaults.
4964#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4965pub struct LoggingConfig {
4966    /// Master toggle. Default `false`.
4967    pub enabled: Option<bool>,
4968    /// Directory for rotated logs. Default `~/.local/state/ai-memory/logs/`.
4969    pub path: Option<String>,
4970    /// Soft cap on a single rotated file (advisory — informs rotation
4971    /// configuration; the appender enforces this via the chosen
4972    /// `rotation` cadence). Default 100.
4973    pub max_size_mb: Option<u64>,
4974    /// Maximum number of rotated files retained on disk. Default 30.
4975    pub max_files: Option<usize>,
4976    /// Days of log history to keep before `ai-memory logs archive`
4977    /// would compress them. Default 90.
4978    pub retention_days: Option<u32>,
4979    /// Emit JSON lines instead of the human-readable fmt layer. Default `false`.
4980    pub structured: Option<bool>,
4981    /// Tracing level / `EnvFilter` directive. Default `"info"`.
4982    pub level: Option<String>,
4983    /// Rotation policy: `minutely | hourly | daily | never`. Default `"daily"`.
4984    pub rotation: Option<String>,
4985    /// Override the rotated-file prefix. Default `"ai-memory.log"`.
4986    pub filename_prefix: Option<String>,
4987}
4988
4989// ---------------------------------------------------------------------------
4990// Audit facility (PR-5)
4991// ---------------------------------------------------------------------------
4992
4993/// `[audit]` block in `config.toml`. Drives the hash-chained audit
4994/// trail emitted from every memory mutation call site.
4995#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4996pub struct AuditConfig {
4997    /// Master toggle. Default `false`.
4998    pub enabled: Option<bool>,
4999    /// Audit log path. Either a directory (in which case `audit.log`
5000    /// is appended) or an explicit file path. Default
5001    /// `~/.local/state/ai-memory/audit/`.
5002    pub path: Option<String>,
5003    /// Documented schema version on the wire. The binary always emits
5004    /// `audit::SCHEMA_VERSION`; this knob is reserved for forward
5005    /// compatibility and must equal the binary's emitted version
5006    /// today (validated at init).
5007    pub schema_version: Option<u32>,
5008    /// Whether to redact `memory.content` from emitted events. **The
5009    /// only supported value in v1 is `true`** — the audit schema does
5010    /// not expose a content field at all; this flag is reserved for a
5011    /// future per-namespace exception API.
5012    pub redact_content: Option<bool>,
5013    /// Whether to compute and verify the per-line hash chain. Default `true`.
5014    pub hash_chain: Option<bool>,
5015    /// Cadence in minutes for the periodic `CHECKPOINT.sig`
5016    /// attestation marker. The marker is a synthetic audit event that
5017    /// pins the chain head into the log so an attacker who truncates
5018    /// the file can't silently rewind history. Default 60. 0 disables.
5019    pub attestation_cadence_minutes: Option<u32>,
5020    /// Apply the platform-appropriate "append-only" file flag at
5021    /// startup. Best-effort defense in depth; the chain is the
5022    /// load-bearing tamper-evidence. Default `true`.
5023    pub append_only: Option<bool>,
5024    /// Retention horizon (days). `ai-memory logs purge` warns about
5025    /// deleting audit records younger than this, and `audit verify`
5026    /// surfaces gaps when retention is shorter than the chain extent.
5027    /// Default 90. Compliance presets override.
5028    pub retention_days: Option<u32>,
5029    /// Compliance presets — apply industry-standard retention /
5030    /// redaction policy on top of the base config. See
5031    /// `docs/security/audit-trail.md` §Compliance.
5032    pub compliance: Option<AuditComplianceConfig>,
5033}
5034
5035impl AuditConfig {
5036    /// Resolve the effective retention horizon after applying any
5037    /// active compliance preset. Presets win when `applied = true`;
5038    /// when multiple presets are applied the most-conservative
5039    /// (longest) retention wins so the binary never picks a value
5040    /// that violates any active policy.
5041    #[must_use]
5042    pub fn effective_retention_days(&self) -> u32 {
5043        let mut chosen = self.retention_days.unwrap_or(90);
5044        if let Some(comp) = &self.compliance {
5045            for preset in comp.applied_presets() {
5046                if let Some(d) = preset.retention_days
5047                    && d > chosen
5048                {
5049                    chosen = d;
5050                }
5051            }
5052        }
5053        chosen
5054    }
5055
5056    /// Resolve the effective attestation cadence — the most-frequent
5057    /// (smallest non-zero) cadence across the base config and applied
5058    /// presets so the strictest compliance rule wins.
5059    #[must_use]
5060    pub fn effective_attestation_cadence_minutes(&self) -> u32 {
5061        let base = self.attestation_cadence_minutes.unwrap_or(60);
5062        let mut chosen = base;
5063        if let Some(comp) = &self.compliance {
5064            for preset in comp.applied_presets() {
5065                if let Some(m) = preset.attestation_cadence_minutes
5066                    && m > 0
5067                    && (chosen == 0 || m < chosen)
5068                {
5069                    chosen = m;
5070                }
5071            }
5072        }
5073        chosen
5074    }
5075}
5076
5077// ---------------------------------------------------------------------------
5078// Boot privacy controls (PR-9h, v0.6.3.1, issue #487 PR #497 req #73)
5079// ---------------------------------------------------------------------------
5080
5081/// `[boot]` block in `config.toml`. Drives the privacy kill-switch +
5082/// title-redaction behaviour of `ai-memory boot`. Both fields default
5083/// to the historical (pre-v0.6.3.1) behaviour so existing users see no
5084/// change.
5085///
5086/// Precedence for `enabled`:
5087///   `AI_MEMORY_BOOT_ENABLED=0` env var (truthy "0/false/no/off") >
5088///   `[boot] enabled` config value > compiled default `true`.
5089#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5090pub struct BootConfig {
5091    /// Master toggle. Default `true`. When set to `false`, `ai-memory
5092    /// boot` exits 0 with **empty stdout AND empty stderr** — the
5093    /// privacy-sensitive escape hatch for hosts where memory titles
5094    /// must never enter CI logs. The hook injects nothing.
5095    pub enabled: Option<bool>,
5096    /// When `true`, the manifest header still appears but every
5097    /// memory row's `title` field is replaced with `<redacted>` —
5098    /// useful for compliance contexts that need an audit trail of
5099    /// "boot ran with N memories" without exposing memory subjects.
5100    /// Default `false`.
5101    pub redact_titles: Option<bool>,
5102}
5103
5104impl BootConfig {
5105    /// Resolve the effective `enabled` value with env-var precedence.
5106    /// `AI_MEMORY_BOOT_ENABLED=0/false/no/off` forces disabled;
5107    /// `=1/true/yes/on` forces enabled. Anything else falls through to
5108    /// the config file value (or the compiled default `true`).
5109    #[must_use]
5110    pub fn effective_enabled(&self) -> bool {
5111        if let Ok(v) = std::env::var("AI_MEMORY_BOOT_ENABLED") {
5112            let v = v.trim().to_ascii_lowercase();
5113            if matches!(v.as_str(), "0" | "false" | "no" | "off") {
5114                return false;
5115            }
5116            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
5117                return true;
5118            }
5119        }
5120        self.enabled.unwrap_or(true)
5121    }
5122
5123    /// Resolve the effective `redact_titles` value. Default `false`.
5124    #[must_use]
5125    pub fn effective_redact_titles(&self) -> bool {
5126        self.redact_titles.unwrap_or(false)
5127    }
5128}
5129
5130// ---------------------------------------------------------------------------
5131// MCP server tunables (v0.6.4)
5132// ---------------------------------------------------------------------------
5133
5134/// `[mcp]` block in `config.toml` — v0.6.4 addition. Today this only
5135/// carries the named tool `profile`. v0.6.4 Track D will extend with
5136/// `[mcp.allowlist]` for per-agent capability gating.
5137///
5138/// Resolution for `profile`: CLI flag > `AI_MEMORY_PROFILE` env (both
5139/// merged by clap) > this config field > compiled default `"core"`.
5140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5141pub struct McpConfig {
5142    /// Named tool profile. One of `core`, `graph`, `admin`, `power`,
5143    /// `full`, or a comma-separated custom list (e.g.,
5144    /// `core,graph,archive`). Default `core` (v0.6.4 default flip).
5145    pub profile: Option<String>,
5146
5147    /// v0.6.4-008 — per-agent capability allowlist. Maps an agent_id
5148    /// pattern to the families that agent may request via
5149    /// `memory_capabilities --include-schema family=<f>`. Patterns
5150    /// resolve to a Vec<String> (the family names). The wildcard
5151    /// pattern `"*"` is the default for agents not otherwise listed.
5152    /// When the entire allowlist is absent (`mcp.allowlist = None`),
5153    /// the gate is disabled — every caller may expand any family
5154    /// (Tier-1 single-process semantics, profile flag rules).
5155    ///
5156    /// Example config.toml:
5157    /// ```toml
5158    /// [mcp.allowlist]
5159    /// "alice" = ["core", "graph"]
5160    /// "bob"   = ["full"]
5161    /// "*"     = ["core"]
5162    /// ```
5163    pub allowlist: Option<std::collections::HashMap<String, Vec<String>>>,
5164
5165    /// #1254 (MED, 2026-05-25) — error-oracle posture for
5166    /// `tools/call` against a tool that exists but is not loaded
5167    /// under the active profile.
5168    ///
5169    /// Default `false` (production-secure): the daemon returns a
5170    /// minimal `"unknown tool: <name>"` regardless of whether the
5171    /// tool exists in another family. This prevents a lower-profile
5172    /// client from probing the surface of a higher-profile tool set
5173    /// (e.g. `admin` or `power` family names) by walking error
5174    /// messages.
5175    ///
5176    /// Set to `true` to restore the v0.6.4-002 helpful hint
5177    /// ("tool 'X' is in family 'Y' which is not loaded under the
5178    /// active profile. Restart with `--profile <name>` ..."). The
5179    /// hint is convenient for single-tenant dev environments where
5180    /// every operator sees the full surface anyway, but leaks
5181    /// family membership in any multi-tenant deployment.
5182    #[serde(default)]
5183    pub profile_hint_in_errors: bool,
5184}
5185
5186impl McpConfig {
5187    /// v0.6.4-008 — resolve the allowlist decision for an agent
5188    /// requesting a family.
5189    ///
5190    /// Returns:
5191    /// - `AllowlistDecision::Disabled` if the entire allowlist is
5192    ///   absent (Tier-1 default — gate is off).
5193    /// - `AllowlistDecision::Allow` if a matching pattern includes
5194    ///   the requested family (or `"full"`).
5195    /// - `AllowlistDecision::Deny` if a pattern matches but does
5196    ///   not list the family.
5197    /// - `AllowlistDecision::Deny` if no pattern matches and there
5198    ///   is no `"*"` wildcard.
5199    ///
5200    /// Pattern matching: exact match wins; otherwise the wildcard
5201    /// `"*"` is consulted. Multiple-pattern precedence follows
5202    /// longest-prefix order with stable tie-break by config order
5203    /// (since `HashMap` is unordered, we sort by key length
5204    /// descending for the comparison).
5205    #[must_use]
5206    pub fn allowlist_decision(&self, agent_id: Option<&str>, family: &str) -> AllowlistDecision {
5207        let table = match self.allowlist.as_ref() {
5208            Some(t) if !t.is_empty() => t,
5209            _ => return AllowlistDecision::Disabled,
5210        };
5211        // Tier-1: no agent_id → only the wildcard rule applies. Same
5212        // restrictive default as for an unknown agent.
5213        let aid = agent_id.unwrap_or("");
5214        // Exact match first.
5215        if let Some(families) = table.get(aid) {
5216            return decide(families, family);
5217        }
5218        // Longest-prefix match next (excluding `"*"`).
5219        let mut keys: Vec<&String> = table
5220            .keys()
5221            .filter(|k| k.as_str() != "*" && aid.starts_with(k.as_str()))
5222            .collect();
5223        keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
5224        if let Some(k) = keys.first() {
5225            if let Some(families) = table.get(*k) {
5226                return decide(families, family);
5227            }
5228        }
5229        // Wildcard fallback.
5230        if let Some(families) = table.get("*") {
5231            return decide(families, family);
5232        }
5233        AllowlistDecision::Deny
5234    }
5235}
5236
5237fn decide(families: &[String], requested: &str) -> AllowlistDecision {
5238    if families.iter().any(|f| f == "full" || f == requested) {
5239        AllowlistDecision::Allow
5240    } else {
5241        AllowlistDecision::Deny
5242    }
5243}
5244
5245/// v0.6.4-008 — outcome of an allowlist check.
5246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5247pub enum AllowlistDecision {
5248    /// Allowlist is not configured; no gate.
5249    Disabled,
5250    /// Pattern match grants access to the requested family.
5251    Allow,
5252    /// Pattern match denies (or no pattern matched).
5253    Deny,
5254}
5255
5256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5257pub struct AuditComplianceConfig {
5258    pub soc2: Option<CompliancePreset>,
5259    pub hipaa: Option<CompliancePreset>,
5260    pub gdpr: Option<CompliancePreset>,
5261    pub fedramp: Option<CompliancePreset>,
5262}
5263
5264impl AuditComplianceConfig {
5265    /// Iterate over every preset whose `applied = true`.
5266    pub fn applied_presets(&self) -> impl Iterator<Item = &CompliancePreset> {
5267        [
5268            self.soc2.as_ref(),
5269            self.hipaa.as_ref(),
5270            self.gdpr.as_ref(),
5271            self.fedramp.as_ref(),
5272        ]
5273        .into_iter()
5274        .flatten()
5275        .filter(|p| p.applied.unwrap_or(false))
5276    }
5277}
5278
5279#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5280pub struct CompliancePreset {
5281    pub applied: Option<bool>,
5282    pub retention_days: Option<u32>,
5283    pub redact_content: Option<bool>,
5284    pub attestation_cadence_minutes: Option<u32>,
5285    /// Reserved for compliance contexts that mandate at-rest crypto.
5286    /// HIPAA preset surfaces this so operators can pair audit with
5287    /// `--features sqlcipher` for end-to-end at-rest encryption.
5288    pub encrypt_at_rest: Option<bool>,
5289    /// GDPR-style actor pseudonymization toggle. Reserved for v0.7+.
5290    pub pseudonymize_actors: Option<bool>,
5291}
5292
5293/// Identity-resolution configuration (Task 1.2 follow-up #198).
5294///
5295/// Lets operators opt out of the default `host:<hostname>:pid-<pid>-<uuid8>`
5296/// fallback when no explicit `agent_id` is supplied. `anonymize_default = true`
5297/// swaps the hostname-revealing default for `anonymous:pid-<pid>-<uuid8>`,
5298/// matching what the `AI_MEMORY_ANONYMIZE=1` env var does ephemerally.
5299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5300pub struct IdentityConfig {
5301    /// When true, the "no flag, no env, no MCP clientInfo" fallback uses
5302    /// `anonymous:pid-<pid>-<uuid8>` instead of the hostname-revealing
5303    /// `host:<hostname>:pid-<pid>-<uuid8>`. Default false.
5304    #[serde(default)]
5305    pub anonymize_default: bool,
5306}
5307
5308/// v0.7.0 (issue #518) — parse a duration string of the form
5309/// `"<integer><unit>"` into a `chrono::Duration`. Supported units:
5310/// `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks).
5311/// Whitespace and case are tolerated. Returns `None` on malformed
5312/// input — the caller falls through to "no since filter applied".
5313///
5314/// Intentionally a small bespoke parser rather than a `humantime`
5315/// dependency: the surface we need is tiny (4-5 units) and operators
5316/// expect the same shape they already type into `--since` flags.
5317#[must_use]
5318pub fn parse_duration_string(s: &str) -> Option<chrono::Duration> {
5319    let trimmed = s.trim().to_ascii_lowercase();
5320    if trimmed.is_empty() {
5321        return None;
5322    }
5323    let (num_part, unit_part) = trimmed.split_at(
5324        trimmed
5325            .find(|c: char| !c.is_ascii_digit())
5326            .unwrap_or(trimmed.len()),
5327    );
5328    let n: i64 = num_part.parse().ok()?;
5329    if n < 0 {
5330        return None;
5331    }
5332    match unit_part.trim() {
5333        "s" | "sec" | "secs" | "second" | "seconds" => Some(chrono::Duration::seconds(n)),
5334        "m" | "min" | "mins" | "minute" | "minutes" => Some(chrono::Duration::minutes(n)),
5335        "h" | "hr" | "hrs" | "hour" | "hours" => Some(chrono::Duration::hours(n)),
5336        "d" | "day" | "days" => Some(chrono::Duration::days(n)),
5337        "w" | "wk" | "wks" | "week" | "weeks" => Some(chrono::Duration::weeks(n)),
5338        _ => None,
5339    }
5340}
5341
5342/// Expand a leading `~` or `~/` in a path string to `$HOME`. POSIX-style.
5343/// `~user/...` is not supported (rare in our deployment surface, and supporting
5344/// it requires `getpwnam` — out of scope for the #507 fix). When `$HOME` is
5345/// unset (no-home environments like some CI containers), the tilde is left
5346/// untouched so the existing failure mode (path not found) is preserved
5347/// rather than silently rewriting to an empty prefix.
5348// ---------------------------------------------------------------------------
5349// Resolver helpers (#1146)
5350// ---------------------------------------------------------------------------
5351
5352/// Backend-specific default model identifier. Used by
5353/// [`AppConfig::resolve_llm`] when no model is configured at any
5354/// precedence layer.
5355fn backend_default_model(backend: &str) -> &'static str {
5356    match backend {
5357        "xai" => "grok-4.3",
5358        "openai" => "gpt-5",
5359        "anthropic" => "claude-opus-4.7",
5360        "gemini" => "gemini-2.0-flash",
5361        "deepseek" => "deepseek-chat",
5362        "kimi" | "moonshot" => "moonshot-v1-8k",
5363        "qwen" | "dashscope" => "qwen-max",
5364        "mistral" => "mistral-large-latest",
5365        "groq" => "llama-3.3-70b-versatile",
5366        "together" => "meta-llama/Llama-3.3-70B-Instruct-Turbo",
5367        "cerebras" => "llama-3.3-70b",
5368        "openrouter" => "openai/gpt-5",
5369        "fireworks" => "accounts/fireworks/models/llama-v3p3-70b-instruct",
5370        "lmstudio" => "local-model",
5371        // ollama / openai-compatible / any unknown alias → legacy default.
5372        _ => "gemma3:4b",
5373    }
5374}
5375
5376/// Backend-specific default base URL. Used by
5377/// [`AppConfig::resolve_llm`] when no base_url is configured at any
5378/// precedence layer. `openai-compatible` returns the empty string (the
5379/// resolver does not validate this — surface plumbing surfaces the
5380/// misconfiguration via the reachability probe in `ai-memory doctor`).
5381fn backend_default_base_url(backend: &str) -> &'static str {
5382    match backend {
5383        "openai" => "https://api.openai.com/v1",
5384        "xai" => "https://api.x.ai/v1",
5385        "anthropic" => "https://api.anthropic.com/v1",
5386        "gemini" => "https://generativelanguage.googleapis.com/v1beta/openai",
5387        "deepseek" => "https://api.deepseek.com/v1",
5388        "kimi" | "moonshot" => "https://api.moonshot.cn/v1",
5389        "qwen" | "dashscope" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
5390        "mistral" => "https://api.mistral.ai/v1",
5391        "groq" => "https://api.groq.com/openai/v1",
5392        "together" => "https://api.together.xyz/v1",
5393        "cerebras" => "https://api.cerebras.ai/v1",
5394        "openrouter" => "https://openrouter.ai/api/v1",
5395        "fireworks" => "https://api.fireworks.ai/inference/v1",
5396        "lmstudio" => "http://localhost:1234/v1",
5397        // ollama / openai-compatible / unknown → localhost ollama.
5398        _ => "http://localhost:11434",
5399    }
5400}
5401
5402/// Per-alias environment variable fallback chain for the API key.
5403/// Mirrors `crate::llm::alias_api_key_env_vars` (kept duplicated to
5404/// avoid a circular dependency between the resolver and the LLM
5405/// client; both lists must stay in sync — pinned by a test in
5406/// commit 12/13).
5407fn alias_api_key_env_vars_for_resolver(alias: &str) -> &'static [&'static str] {
5408    match alias {
5409        "openai" => &["OPENAI_API_KEY"],
5410        "xai" => &["XAI_API_KEY"],
5411        "anthropic" => &["ANTHROPIC_API_KEY"],
5412        "gemini" => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
5413        "deepseek" => &["DEEPSEEK_API_KEY"],
5414        "kimi" | "moonshot" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
5415        "qwen" | "dashscope" => &["DASHSCOPE_API_KEY", "QWEN_API_KEY"],
5416        "mistral" => &["MISTRAL_API_KEY"],
5417        "groq" => &["GROQ_API_KEY"],
5418        "together" => &["TOGETHER_API_KEY"],
5419        "cerebras" => &["CEREBRAS_API_KEY"],
5420        "openrouter" => &["OPENROUTER_API_KEY"],
5421        "fireworks" => &["FIREWORKS_API_KEY"],
5422        _ => &[],
5423    }
5424}
5425
5426/// Canonicalise legacy embedding-model aliases to the HF-id form. Lets
5427/// existing config.toml files with `embedding_model = "nomic_embed_v15"`
5428/// continue to work while the resolver returns the canonical id used
5429/// throughout the substrate.
5430fn canonicalise_embedding_model(raw: String) -> String {
5431    match raw.trim() {
5432        "nomic_embed_v15" => "nomic-embed-text-v1.5".to_string(),
5433        "mini_lm_l6_v2" => "sentence-transformers/all-MiniLM-L6-v2".to_string(),
5434        _ => raw,
5435    }
5436}
5437
5438/// v0.7.x (issue #1169) — known canonical embedding-model id → vector
5439/// dim mappings.
5440///
5441/// Used by [`canonical_embedding_dim`] (resolver-side) and
5442/// [`build_capability_models`] (capabilities-surface side) so the
5443/// reported `embedding_dim` reflects the live model the embedder
5444/// produces vectors of, NOT the compiled tier preset's hardcoded dim.
5445/// Pre-#1169 the dim was sourced only from the 2-family
5446/// [`EmbeddingModel`] enum — picking any other model id (e.g. Ollama
5447/// `bge-large-en`) silently fell back to the tier preset's wrong dim.
5448///
5449/// New entries land here when an operator adopts a model not yet
5450/// covered. Unknown models resolve to `None`
5451/// ([`canonical_embedding_dim`] return), which causes
5452/// [`build_capability_models`] to fall back to the tier preset's dim
5453/// — preserving the pre-#1169 behaviour for unrecognised ids and
5454/// avoiding the silent-wrong-dim trap for recognised ones.
5455///
5456/// Match keys are case-insensitive (lookup uses
5457/// `eq_ignore_ascii_case`) and span the canonical HF id, the
5458/// unprefixed shortname, and the common Ollama tag where they
5459/// diverge. Matches whatever the operator actually wrote in
5460/// `[embeddings].model` post-`canonicalise_embedding_model`.
5461pub const KNOWN_EMBEDDING_DIMS: &[(&str, u32)] = &[
5462    // nomic-ai (default for the v0.7.0 autonomous tier)
5463    ("nomic-embed-text-v1.5", 768),
5464    ("nomic-embed-text", 768),
5465    ("nomic-ai/nomic-embed-text-v1.5", 768),
5466    // sentence-transformers / MiniLM family
5467    ("sentence-transformers/all-MiniLM-L6-v2", 384),
5468    ("all-MiniLM-L6-v2", 384),
5469    ("all-minilm", 384),
5470    ("all-minilm:l6-v2", 384),
5471    // BAAI BGE family (common Ollama-side operator picks — the #1169
5472    // repro example was bge-large-en)
5473    ("bge-large-en", 1024),
5474    ("bge-large-en-v1.5", 1024),
5475    ("baai/bge-large-en-v1.5", 1024),
5476    ("bge-base-en", 768),
5477    ("bge-base-en-v1.5", 768),
5478    ("baai/bge-base-en-v1.5", 768),
5479    ("bge-small-en", 384),
5480    ("bge-small-en-v1.5", 384),
5481    ("baai/bge-small-en-v1.5", 384),
5482    ("bge-m3", 1024),
5483    ("baai/bge-m3", 1024),
5484    // Mixed Bread AI
5485    ("mxbai-embed-large", 1024),
5486    ("mxbai-embed-large-v1", 1024),
5487    ("mixedbread-ai/mxbai-embed-large-v1", 1024),
5488    // OpenAI text-embedding family
5489    ("text-embedding-3-small", 1536),
5490    ("text-embedding-3-large", 3072),
5491    ("text-embedding-ada-002", 1536),
5492    // Google embedding
5493    ("embedding-001", 768),
5494    ("text-embedding-004", 768),
5495    ("google/gemini-embedding-2", 3072),
5496    ("gemini-embedding-2", 3072),
5497    // IBM Granite (#1598 — common self-hosted TEI/vLLM pick)
5498    ("ibm-granite/granite-embedding-125m-english", 768),
5499    ("granite-embedding", 768),
5500    // Snowflake Arctic
5501    ("snowflake-arctic-embed", 1024),
5502    ("snowflake-arctic-embed:l", 1024),
5503    ("snowflake-arctic-embed-l", 1024),
5504    ("snowflake-arctic-embed:m", 768),
5505    ("snowflake-arctic-embed:s", 384),
5506];
5507
5508/// v0.7.x (issue #1169) — look up the vector dim for a canonical
5509/// embedding model id. Returns `None` when the model is not in the
5510/// [`KNOWN_EMBEDDING_DIMS`] table; callers fall back to the tier
5511/// preset (preserving pre-#1169 behaviour for unrecognised ids).
5512///
5513/// The lookup is case-insensitive and ignores leading/trailing
5514/// whitespace. Matches the canonicalised form
5515/// ([`canonicalise_embedding_model`] runs first), so the table
5516/// keys are the HF-id / Ollama tag forms operators actually set in
5517/// `[embeddings].model` after legacy-alias canonicalisation.
5518#[must_use]
5519pub fn canonical_embedding_dim(model: &str) -> Option<u32> {
5520    let needle = model.trim();
5521    if needle.is_empty() {
5522        return None;
5523    }
5524    KNOWN_EMBEDDING_DIMS
5525        .iter()
5526        .find(|(id, _)| id.eq_ignore_ascii_case(needle))
5527        .map(|(_, dim)| *dim)
5528}
5529
5530/// Resolve the API key + provenance tag for the configured backend.
5531///
5532/// Precedence:
5533///   1. `AI_MEMORY_LLM_API_KEY` process env → `KeySource::ProcessEnv`
5534///   2. Per-vendor process env-var fallback (e.g. `XAI_API_KEY`)
5535///      → `KeySource::AliasFallback(name)`
5536///   3. `[llm].api_key_env` → `KeySource::ConfigEnvVar(name)`
5537///   4. `[llm].api_key_file` → `KeySource::ConfigFile(path)`
5538///   5. None resolved → `KeySource::None` (correct for `backend =
5539///      "ollama"`; a misconfiguration for OpenAI-compatible vendors —
5540///      surfaced by the reachability probe).
5541///
5542/// #1598 — thin delegate over [`resolve_api_key_ladder`] (the same
5543/// ladder serves the `[embeddings]` section via
5544/// [`resolve_embed_api_key`]).
5545fn resolve_api_key(backend: &str, llm: Option<&LlmSection>) -> (Option<String>, KeySource) {
5546    resolve_api_key_ladder(
5547        ENV_LLM_API_KEY,
5548        backend,
5549        llm.and_then(|l| l.api_key_env.as_deref()),
5550        llm.and_then(|l| l.api_key_file.as_deref()),
5551        "llm",
5552    )
5553}
5554
5555/// #1598 — resolve the EMBEDDING API key + provenance tag for the
5556/// configured embedding backend. Mirrors [`resolve_api_key`] with the
5557/// `[embeddings]`-section sources:
5558///
5559///   1. `AI_MEMORY_EMBED_API_KEY` process env → `KeySource::ProcessEnv`
5560///   2. Per-vendor process env-var fallback (e.g. `OPENROUTER_API_KEY`)
5561///      → `KeySource::AliasFallback(name)`
5562///   3. `[embeddings].api_key_env` → `KeySource::ConfigEnvVar(name)`
5563///   4. `[embeddings].api_key_file` (0400 enforced)
5564///      → `KeySource::ConfigFile(path)`
5565///   5. None resolved → `KeySource::None` (correct for `backend =
5566///      "ollama"` and for keyless self-hosted OpenAI-compatible
5567///      endpoints such as HF TEI / vLLM).
5568fn resolve_embed_api_key(
5569    backend: &str,
5570    embeddings: Option<&EmbeddingsSection>,
5571) -> (Option<String>, KeySource) {
5572    resolve_api_key_ladder(
5573        ENV_EMBED_API_KEY,
5574        backend,
5575        embeddings.and_then(|e| e.api_key_env.as_deref()),
5576        embeddings.and_then(|e| e.api_key_file.as_deref()),
5577        "embeddings",
5578    )
5579}
5580
5581/// #1598 — true when the embedding backend speaks an API wire shape
5582/// (OpenAI-compatible `/embeddings` + Bearer auth) rather than the
5583/// local Ollama-native `/api/embed` shape. `"ollama"` is the ONLY
5584/// non-API backend; every #1067 alias and the generic
5585/// `openai-compatible` escape hatch classify as API backends. Sits
5586/// next to [`alias_api_key_env_vars_for_resolver`] /
5587/// [`backend_default_base_url`] — the alias machinery it complements.
5588#[must_use]
5589pub fn is_api_embed_backend(backend: &str) -> bool {
5590    !backend
5591        .trim()
5592        .eq_ignore_ascii_case(crate::llm::BACKEND_OLLAMA)
5593}
5594
5595/// Shared API-key resolution ladder for the `[llm]` and `[embeddings]`
5596/// sections (#1146 / #1598). `primary_env` is the section's dedicated
5597/// `AI_MEMORY_*_API_KEY` env var; `section` is the bare section name
5598/// (`"llm"` / `"embeddings"`) used in provenance / error strings.
5599///
5600/// File reads enforce mode 0400 (via [`enforce_api_key_file_perms`])
5601/// and surface failures as `KeySource::Error(reason)` so the daemon
5602/// can boot and report the problem through `ai-memory doctor` rather
5603/// than failing at config load.
5604fn resolve_api_key_ladder(
5605    primary_env: &str,
5606    backend: &str,
5607    api_key_env: Option<&str>,
5608    api_key_file: Option<&str>,
5609    section: &str,
5610) -> (Option<String>, KeySource) {
5611    // 1. Process env (highest).
5612    if let Some(k) = std::env::var(primary_env)
5613        .ok()
5614        .filter(|s| !s.trim().is_empty())
5615    {
5616        return (Some(k), KeySource::ProcessEnv);
5617    }
5618
5619    // 2. Per-vendor alias fallback.
5620    for name in alias_api_key_env_vars_for_resolver(backend) {
5621        if let Some(k) = std::env::var(name).ok().filter(|s| !s.trim().is_empty()) {
5622            return (Some(k), KeySource::AliasFallback((*name).to_string()));
5623        }
5624    }
5625
5626    // 3. config-pointed env var.
5627    if let Some(name) = api_key_env.filter(|s| !s.trim().is_empty()) {
5628        return match std::env::var(name) {
5629            Ok(v) if !v.trim().is_empty() => (Some(v), KeySource::ConfigEnvVar(name.to_string())),
5630            Ok(_) => (
5631                None,
5632                KeySource::Error(format!(
5633                    "[{section}].api_key_env = {name:?} resolves to an empty env var"
5634                )),
5635            ),
5636            Err(_) => (
5637                None,
5638                KeySource::Error(format!(
5639                    "[{section}].api_key_env = {name:?} is not set in the process env"
5640                )),
5641            ),
5642        };
5643    }
5644
5645    // 4. config-pointed file.
5646    if let Some(raw_path) = api_key_file.filter(|s| !s.trim().is_empty()) {
5647        let field = format!("[{section}].api_key_file");
5648        let path = expand_tilde(raw_path);
5649        let path_display = path.display().to_string();
5650
5651        // Mode 0400 enforcement (#1055-style escape hatch).
5652        if let Err(reason) = enforce_api_key_file_perms(&path, &field) {
5653            return (None, KeySource::Error(reason));
5654        }
5655
5656        return match std::fs::read_to_string(&path) {
5657            Ok(contents) => {
5658                let key = contents.lines().next().unwrap_or("").trim().to_string();
5659                if key.is_empty() {
5660                    (
5661                        None,
5662                        KeySource::Error(format!("{field} = {path_display:?} is empty")),
5663                    )
5664                } else {
5665                    (Some(key), KeySource::ConfigFile(path_display))
5666                }
5667            }
5668            Err(e) => (
5669                None,
5670                KeySource::Error(format!("{field} = {path_display:?} could not be read: {e}")),
5671            ),
5672        };
5673    }
5674
5675    (None, KeySource::None)
5676}
5677
5678/// v0.7.x (#1146) — enforce mode 0400 (or stricter) on the file
5679/// referenced by `[llm].api_key_file` / `[embeddings].api_key_file`
5680/// (#1598; `field` names the rejecting config field in error text).
5681/// The check mirrors the existing `AI_MEMORY_DB_PASSPHRASE_FILE`
5682/// enforcement (issue #1055): any bits set in `mode & 0o077` (group /
5683/// world readable / executable) cause the daemon to refuse the file,
5684/// unless the operator opts out via
5685/// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1`.
5686///
5687/// On non-Unix platforms (the `staticlib` mobile target, future
5688/// Windows builds) the check is a no-op — the perm bits are not
5689/// expressible on those platforms.
5690fn enforce_api_key_file_perms(path: &Path, field: &str) -> Result<(), String> {
5691    #[cfg(unix)]
5692    {
5693        use std::os::unix::fs::PermissionsExt;
5694        let metadata = std::fs::metadata(path).map_err(|e| {
5695            format!(
5696                "{field} = {:?} could not be stat'd for perms check: {e}",
5697                path.display(),
5698            )
5699        })?;
5700        let mode = metadata.permissions().mode();
5701        if mode & 0o077 != 0 {
5702            // Allow lax perms only when the operator explicitly opts in
5703            // (mirroring #1055 for AI_MEMORY_DB_PASSPHRASE_FILE).
5704            let opt_in = std::env::var("AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS")
5705                .ok()
5706                .is_some_and(|s| {
5707                    let t = s.trim().to_ascii_lowercase();
5708                    matches!(t.as_str(), "1" | "true" | "yes" | "on")
5709                });
5710            if !opt_in {
5711                return Err(format!(
5712                    "{field} = {:?} has lax permissions \
5713                     (mode = {:o}; expected 0400 or stricter). Run \
5714                     `chmod 0400 {}` to fix, or set \
5715                     `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1` to \
5716                     bypass (NOT recommended for production).",
5717                    path.display(),
5718                    mode & 0o777,
5719                    path.display()
5720                ));
5721            }
5722            tracing::warn!(
5723                "{field} = {:?} has lax permissions (mode = {:o}); \
5724                 accepted because AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1",
5725                path.display(),
5726                mode & 0o777
5727            );
5728        }
5729    }
5730    #[cfg(not(unix))]
5731    {
5732        // Permission bits do not apply on non-Unix platforms.
5733        let _ = (path, field);
5734    }
5735    Ok(())
5736}
5737
5738fn expand_tilde(s: &str) -> PathBuf {
5739    if s == "~" {
5740        return std::env::var("HOME").map_or_else(|_| PathBuf::from(s), PathBuf::from);
5741    }
5742    if let Some(rest) = s.strip_prefix("~/") {
5743        return std::env::var("HOME")
5744            .map_or_else(|_| PathBuf::from(s), |h| PathBuf::from(h).join(rest));
5745    }
5746    PathBuf::from(s)
5747}
5748
5749impl AppConfig {
5750    /// Returns the config file path: `~/.config/ai-memory/config.toml`
5751    pub fn config_path() -> Option<PathBuf> {
5752        let home = std::env::var("HOME").ok()?;
5753        Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
5754    }
5755
5756    /// Load config from disk. Returns `AppConfig::default()` if file is missing.
5757    /// Set `AI_MEMORY_NO_CONFIG=1` to skip config loading (used by integration tests).
5758    pub fn load() -> Self {
5759        if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
5760            return Self::default();
5761        }
5762        let Some(path) = Self::config_path() else {
5763            return Self::default();
5764        };
5765        Self::load_from(&path)
5766    }
5767
5768    /// Load config from a specific path.
5769    pub fn load_from(path: &Path) -> Self {
5770        match std::fs::read_to_string(path) {
5771            Ok(contents) => {
5772                // L1 fix (v0.7.0): warn on unknown top-level keys.
5773                // `serde(deny_unknown_fields)` would be a breaking change for
5774                // operators carrying forward-compat config snippets, so we
5775                // instead parse the document twice: once as a generic
5776                // `toml::Value` to enumerate every top-level key, and once
5777                // into `AppConfig` as before. Any top-level key that is not
5778                // part of the expected `AppConfig` field set is reported via
5779                // `tracing::warn!` and otherwise silently ignored — load
5780                // continues to succeed so a typo or stale Plan C section
5781                // (`[memory]`, `[autonomous]`, `[governance]`, `[federation]`)
5782                // can no longer silently neutralise an operator's intent.
5783                Self::warn_unknown_top_level_keys(path, &contents);
5784                match toml::from_str::<Self>(&contents) {
5785                    Ok(cfg) => match cfg.validate_secret_handling() {
5786                        Ok(()) => {
5787                            eprintln!("ai-memory: loaded config from {}", path.display());
5788                            cfg.warn_legacy_schema_drift(path);
5789                            cfg
5790                        }
5791                        Err(reason) => {
5792                            eprintln!(
5793                                "ai-memory: config rejected ({}): {}\n\
5794                                 ai-memory: falling back to default config — \
5795                                 fix the issue and restart. \
5796                                 See https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
5797                                path.display(),
5798                                reason
5799                            );
5800                            Self::default()
5801                        }
5802                    },
5803                    Err(e) => {
5804                        eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
5805                        Self::default()
5806                    }
5807                }
5808            }
5809            Err(_) => Self::default(),
5810        }
5811    }
5812
5813    /// v0.7.x (#1146) — emit a one-shot deprecation WARN to stderr
5814    /// when the loaded config carries legacy v1 flat fields that have
5815    /// been superseded by the sectioned v2 schema.
5816    ///
5817    /// Two posture WARNs:
5818    ///
5819    /// - **Legacy-only** (no `schema_version` OR `schema_version = 1`,
5820    ///   AND any of `llm_model`, `ollama_url`, `embed_url`,
5821    ///   `embedding_model`, `cross_encoder`, `default_namespace`,
5822    ///   `archive_on_gc`, `archive_max_days`, `max_memory_mb`,
5823    ///   `auto_tag_model` set): operator running pre-#1146 config
5824    ///   shape — point them at `ai-memory config migrate`.
5825    ///
5826    /// - **Drift** (`schema_version >= 2` AND any legacy field set):
5827    ///   operator has migrated but left legacy fields in place —
5828    ///   legacy fields are ignored under v2, point them at
5829    ///   `ai-memory config migrate` to clean up the dead weight.
5830    ///
5831    /// The WARN is gated by [`std::sync::Once`] so re-loading the
5832    /// config in the same process (e.g. tests that call
5833    /// [`AppConfig::load_from`] in a loop) does not spam stderr.
5834    ///
5835    /// DOC-6 (FX-C4-batch2, 2026-05-26): the v2 sectioned schema
5836    /// resolution path intentionally reads the legacy fields to
5837    /// emit the warn — the `#[allow(deprecated)]` is scoped here
5838    /// so the WARN site (the only thing that legitimately TOUCHES
5839    /// the legacy fields post-#1146) doesn't cascade pedantic
5840    /// errors. External consumers writing `let cfg: AppConfig =
5841    /// ...; cfg.llm_model` still get the compile-time deprecation
5842    /// warning.
5843    #[allow(deprecated)]
5844    fn warn_legacy_schema_drift(&self, path: &Path) {
5845        use std::sync::Once;
5846        static WARN_ONCE: Once = Once::new();
5847
5848        let has_legacy = self.llm_model.is_some()
5849            || self.ollama_url.is_some()
5850            || self.embed_url.is_some()
5851            || self.embedding_model.is_some()
5852            || self.cross_encoder.is_some()
5853            || self.default_namespace.is_some()
5854            || self.archive_on_gc.is_some()
5855            || self.archive_max_days.is_some()
5856            || self.max_memory_mb.is_some()
5857            || self.auto_tag_model.is_some();
5858
5859        if !has_legacy {
5860            return;
5861        }
5862
5863        let v2 = matches!(self.schema_version, Some(v) if v >= 2);
5864
5865        WARN_ONCE.call_once(|| {
5866            if v2 {
5867                eprintln!(
5868                    "ai-memory: WARN — schema_version = {:?} but legacy v1 fields \
5869                     are still present in {} (llm_model / ollama_url / embed_url / \
5870                     embedding_model / cross_encoder / default_namespace / \
5871                     archive_on_gc / archive_max_days / max_memory_mb / \
5872                     auto_tag_model). Under v2 the legacy fields are IGNORED in \
5873                     favor of [llm] / [embeddings] / [reranker] / [storage] \
5874                     sections. Run `ai-memory config migrate` to remove them.",
5875                    self.schema_version,
5876                    path.display(),
5877                );
5878            } else {
5879                eprintln!(
5880                    "ai-memory: WARN — legacy v1 flat-field configuration shape \
5881                     detected in {}. The [llm] / [embeddings] / [reranker] / \
5882                     [storage] sectioned schema (v2) is the canonical shape; \
5883                     legacy fields continue to work in v0.7.x but will be \
5884                     removed in v0.8.0. Run `ai-memory config migrate` to \
5885                     upgrade in place (a timestamped .bak is written). See \
5886                     https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
5887                    path.display(),
5888                );
5889            }
5890        });
5891    }
5892
5893    /// v0.7.x (#1146) — validate secret-handling discipline in the
5894    /// `[llm]` (and `[llm.auto_tag]`) sections after parse. Three
5895    /// rejections fire at load time so misconfigurations are loud
5896    /// rather than silent:
5897    ///
5898    /// 1. Inline `api_key = "<literal>"` in `[llm]`. Operators MUST
5899    ///    use `api_key_env = "<ENV_VAR_NAME>"` or `api_key_file =
5900    ///    "/path/to/key"` instead. Closes the v0.6.x posture where
5901    ///    inline secrets in `~/.config/ai-memory/config.toml` were
5902    ///    silently accepted even though the file is typically
5903    ///    world-readable.
5904    ///
5905    /// 2. Both `api_key_env` and `api_key_file` set on `[llm]`.
5906    ///    Mutually exclusive — operator must pick one.
5907    ///
5908    /// 3. Both `api_key_env` and `api_key_file` set on
5909    ///    `[llm.auto_tag]`. Same mutex.
5910    ///
5911    /// 4. (#1598) Inline `api_key = "<literal>"` in `[embeddings]` —
5912    ///    same posture as rejection 1.
5913    ///
5914    /// 5. (#1598) Both `api_key_env` and `api_key_file` set on
5915    ///    `[embeddings]`. Same mutex as rejection 2.
5916    ///
5917    /// On any rejection, [`Self::load_from`] surfaces the message to
5918    /// stderr and falls back to [`Self::default`] so the daemon boots
5919    /// without the misconfigured secret rather than refusing to start
5920    /// entirely.
5921    fn validate_secret_handling(&self) -> Result<(), String> {
5922        if let Some(llm) = &self.llm {
5923            // Rejection 1 — inline api_key literal.
5924            if llm.api_key.is_some() {
5925                return Err("inline `api_key = \"<literal>\"` in [llm] is forbidden — \
5926                     use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
5927                     process env var, or `api_key_file = \"/path/to/key\"` to \
5928                     reference a file (mode 0400 enforced). Inline secrets in \
5929                     config.toml (typically world-readable) are a credential \
5930                     leak."
5931                    .to_string());
5932            }
5933            // Rejection 2 — env vs file mutex.
5934            if llm.api_key_env.is_some() && llm.api_key_file.is_some() {
5935                return Err("[llm].api_key_env and [llm].api_key_file are mutually \
5936                     exclusive — set exactly one (or neither, to fall back \
5937                     to the per-vendor env-var chain)."
5938                    .to_string());
5939            }
5940            // Rejection 3 — auto_tag env vs file mutex.
5941            if let Some(auto_tag) = &llm.auto_tag {
5942                if auto_tag.api_key_env.is_some() && auto_tag.api_key_file.is_some() {
5943                    return Err("[llm.auto_tag].api_key_env and \
5944                         [llm.auto_tag].api_key_file are mutually exclusive."
5945                        .to_string());
5946                }
5947            }
5948        }
5949        if let Some(embeddings) = &self.embeddings {
5950            // #1598 Rejection 4 — inline [embeddings].api_key literal
5951            // (mirrors the [llm] rejection above).
5952            if embeddings.api_key.is_some() {
5953                return Err(
5954                    "inline `api_key = \"<literal>\"` in [embeddings] is forbidden — \
5955                     use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
5956                     process env var, or `api_key_file = \"/path/to/key\"` to \
5957                     reference a file (mode 0400 enforced). Inline secrets in \
5958                     config.toml (typically world-readable) are a credential \
5959                     leak."
5960                        .to_string(),
5961                );
5962            }
5963            // #1598 Rejection 5 — [embeddings] env vs file mutex.
5964            if embeddings.api_key_env.is_some() && embeddings.api_key_file.is_some() {
5965                return Err(
5966                    "[embeddings].api_key_env and [embeddings].api_key_file are \
5967                     mutually exclusive — set exactly one (or neither, to fall \
5968                     back to the per-vendor env-var chain)."
5969                        .to_string(),
5970                );
5971            }
5972        }
5973        Ok(())
5974    }
5975
5976    /// L1 fix (v0.7.0): enumerate top-level keys in `contents` and emit a
5977    /// `tracing::warn!` for every key that is not a recognised `AppConfig`
5978    /// field. Malformed TOML is silently skipped here — the existing
5979    /// `toml::from_str::<AppConfig>` parse in `load_from` will surface the
5980    /// real parse error to the operator on the next line.
5981    fn warn_unknown_top_level_keys(path: &Path, contents: &str) {
5982        // Canonical list of `AppConfig` top-level fields. Keep in sync with
5983        // the struct definition above; verified verbatim against the v0.7.0
5984        // L1 spec.
5985        const EXPECTED_KEYS: &[&str] = &[
5986            "tier",
5987            "db",
5988            config_keys::OLLAMA_URL,
5989            "embed_url",
5990            config_keys::EMBEDDING_MODEL,
5991            "llm_model",
5992            config_keys::AUTO_TAG_MODEL,
5993            config_keys::CROSS_ENCODER,
5994            config_keys::DEFAULT_NAMESPACE,
5995            config_keys::MAX_MEMORY_MB,
5996            "ttl",
5997            config_keys::ARCHIVE_ON_GC,
5998            "api_key",
5999            config_keys::ARCHIVE_MAX_DAYS,
6000            "identity",
6001            "scoring",
6002            "autonomous_hooks",
6003            "logging",
6004            "audit",
6005            "boot",
6006            "mcp",
6007            "permissions",
6008            "transcripts",
6009            "hooks",
6010            "subscriptions",
6011            "postgres_statement_timeout_secs",
6012            "postgres_pool_max_connections",
6013            "postgres_pool_min_connections",
6014            "postgres_acquire_timeout_secs",
6015            "request_timeout_secs",
6016            "llm_call_timeout_secs",
6017            "verify",
6018            "mcp_federation_forward_url",
6019            "agents",
6020            "governance",
6021            "confidence",
6022            "admin",
6023            // v0.7.x (#1146) — enterprise configuration sections.
6024            "schema_version",
6025            "llm",
6026            config_keys::SECTION_EMBEDDINGS,
6027            "reranker",
6028            "storage",
6029            "limits",
6030        ];
6031
6032        let value: toml::Value = match toml::from_str(contents) {
6033            Ok(v) => v,
6034            // Malformed TOML — defer to the strongly-typed parse in the
6035            // caller, which produces the operator-facing error message.
6036            Err(_) => return,
6037        };
6038
6039        let Some(table) = value.as_table() else {
6040            return;
6041        };
6042
6043        let expected_list = EXPECTED_KEYS.join(", ");
6044        for key in table.keys() {
6045            if !EXPECTED_KEYS.contains(&key.as_str()) {
6046                tracing::warn!(
6047                    "[config] unknown key '{key}' in {path} — top-level AppConfig fields are: {expected_keys}. This key is silently ignored (no behavior change).",
6048                    key = key,
6049                    path = path.display(),
6050                    expected_keys = expected_list,
6051                );
6052            }
6053        }
6054    }
6055
6056    /// v0.7.0 K3 — resolve the effective [`PermissionsMode`] consulted
6057    /// by [`crate::db::enforce_governance`].
6058    ///
6059    /// Resolution order:
6060    /// 1. `AI_MEMORY_PERMISSIONS_MODE` env var (`enforce` /
6061    ///    `advisory` / `off`, case-insensitive). Lets the integration
6062    ///    suite — which sets `AI_MEMORY_NO_CONFIG=1` and therefore
6063    ///    cannot use `[permissions]` from `config.toml` — flip the
6064    ///    gate to Enforce per scenario.
6065    /// 2. `[permissions].mode` from `config.toml`.
6066    /// 3. v0.7.0 secure default ([`PermissionsMode::Enforce`]) when no
6067    ///    explicit configuration is present. Round-2 F8 / Round-3
6068    ///    re-verify: prior to this round the unconfigured fallback was
6069    ///    [`PermissionsMode::default`] (= `advisory`), which left an
6070    ///    upgrading deployment with `metadata.governance.write=owner`
6071    ///    bypassable. We now resolve via
6072    ///    [`crate::permissions::resolve_v07_default_mode`] so every
6073    ///    process-wide entry point (CLI, MCP, HTTP serve) shares the
6074    ///    same secure-by-default posture; operators who want advisory
6075    ///    set `[permissions].mode = "advisory"` explicitly.
6076    #[must_use]
6077    pub fn effective_permissions_mode(&self) -> PermissionsMode {
6078        if let Ok(raw) = std::env::var("AI_MEMORY_PERMISSIONS_MODE") {
6079            match raw.to_ascii_lowercase().as_str() {
6080                "enforce" => return PermissionsMode::Enforce,
6081                "advisory" => return PermissionsMode::Advisory,
6082                "off" => return PermissionsMode::Off,
6083                other => {
6084                    eprintln!(
6085                        "ai-memory: AI_MEMORY_PERMISSIONS_MODE={other:?} is not a valid mode \
6086                         (expected enforce / advisory / off); falling back to config.toml"
6087                    );
6088                }
6089            }
6090        }
6091        // B4 (S5-M3) — both "block absent entirely" and "block present
6092        // but `mode =` omitted" must reach the secure default. The
6093        // `Option<PermissionsMode>` shape lets us collapse both to
6094        // `None` for the resolver so neither path silently inherits
6095        // the serde-derived `Advisory`. The migration WARN that
6096        // `resolve_v07_default_mode` emits when configured is `None`
6097        // is surfaced by the daemon's startup banner
6098        // (see `crate::cli::serve_banner::compose_banner`).
6099        let configured = self.permissions.as_ref().and_then(|p| p.mode);
6100        let (mode, _warn) = crate::permissions::resolve_v07_default_mode(configured);
6101        mode
6102    }
6103
6104    /// v0.7.0 K9 — resolve the effective declarative rule set
6105    /// consulted by [`crate::permissions::Permissions::evaluate`].
6106    ///
6107    /// Returns the rules from `[permissions]` when configured;
6108    /// otherwise an empty vec (no declarative rules — mode + hooks
6109    /// resolve every decision).
6110    #[must_use]
6111    pub fn effective_permission_rules(&self) -> Vec<crate::permissions::PermissionRule> {
6112        self.permissions
6113            .as_ref()
6114            .map(|p| p.rules.clone())
6115            .unwrap_or_default()
6116    }
6117
6118    /// Resolve the effective feature tier from config (CLI flag overrides).
6119    pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
6120        let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
6121        FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
6122    }
6123
6124    /// Resolve the effective database path (CLI flag overrides config).
6125    ///
6126    /// Expands a leading `~` / `~/` in the config-provided path to `$HOME`
6127    /// before returning (issue #507). Without this, `db = "~/.claude/ai-memory.db"`
6128    /// in `config.toml` would land on disk as the literal four-char dir
6129    /// `~/.claude/...` relative to cwd and the daemon would report
6130    /// `warn db unavailable` against the real DB that lives at the
6131    /// expanded path.
6132    pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
6133        // If CLI provided a non-default path, use it
6134        let default_db = PathBuf::from("ai-memory.db");
6135        if cli_db != default_db {
6136            return cli_db.to_path_buf();
6137        }
6138        // Otherwise check config — expanding leading `~` against $HOME.
6139        self.db
6140            .as_ref()
6141            .map_or_else(|| cli_db.to_path_buf(), |s| expand_tilde(s))
6142    }
6143
6144    /// Resolve Ollama URL for LLM generation (config or default).
6145    ///
6146    /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6147    /// New callers should use the sectioned `[llm]` resolver.
6148    #[allow(deprecated)]
6149    pub fn effective_ollama_url(&self) -> &str {
6150        self.ollama_url
6151            .as_deref()
6152            .unwrap_or("http://localhost:11434")
6153    }
6154
6155    /// Resolve TTL configuration from config file, falling back to compiled defaults.
6156    pub fn effective_ttl(&self) -> ResolvedTtl {
6157        ResolvedTtl::from_config(self.ttl.as_ref())
6158    }
6159
6160    /// Resolve recall-scoring configuration (time-decay half-life) from the
6161    /// config file, falling back to compiled defaults. v0.6.0.0.
6162    pub fn effective_scoring(&self) -> ResolvedScoring {
6163        ResolvedScoring::from_config(self.scoring.as_ref())
6164    }
6165
6166    /// Whether to archive memories before GC deletion (default: true).
6167    ///
6168    /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6169    #[allow(deprecated)]
6170    pub fn effective_archive_on_gc(&self) -> bool {
6171        self.archive_on_gc.unwrap_or(true)
6172    }
6173
6174    /// v0.7.0 H7 (round-2) — resolved per-request HTTP timeout.
6175    /// Falls back to [`DEFAULT_REQUEST_TIMEOUT_SECS`] when the
6176    /// `request_timeout_secs` config field is unset.
6177    #[must_use]
6178    pub fn effective_request_timeout_secs(&self) -> u64 {
6179        self.request_timeout_secs
6180            .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS)
6181    }
6182
6183    /// v0.7.0 H8 (round-2) — resolved per-LLM-call timeout. Falls
6184    /// back to [`DEFAULT_LLM_CALL_TIMEOUT_SECS`] when the
6185    /// `llm_call_timeout_secs` config field is unset.
6186    #[must_use]
6187    pub fn effective_llm_call_timeout_secs(&self) -> u64 {
6188        self.llm_call_timeout_secs
6189            .unwrap_or(DEFAULT_LLM_CALL_TIMEOUT_SECS)
6190    }
6191
6192    /// v0.6.4-001 — resolve the effective MCP tool profile.
6193    ///
6194    /// Resolution order:
6195    /// 1. `cli_or_env` (already merged by clap's `#[arg(env="AI_MEMORY_PROFILE")]`)
6196    /// 2. `[mcp].profile` config field
6197    /// 3. compiled default `"core"`
6198    ///
6199    /// # Errors
6200    ///
6201    /// Returns [`crate::profile::ProfileParseError`] if any layer's
6202    /// value is malformed (unknown family or mixed-case token).
6203    pub fn effective_profile(
6204        &self,
6205        cli_or_env: Option<&str>,
6206    ) -> Result<crate::profile::Profile, crate::profile::ProfileParseError> {
6207        let raw = cli_or_env
6208            .or_else(|| self.mcp.as_ref().and_then(|m| m.profile.as_deref()))
6209            .unwrap_or("core");
6210        crate::profile::Profile::parse(raw)
6211    }
6212
6213    /// Whether post-store autonomy hooks (`auto_tag` + `detect_contradiction`)
6214    /// fire on every successful `memory_store`. v0.6.0.0.
6215    /// Precedence: `AI_MEMORY_AUTONOMOUS_HOOKS=1` env var (truthy) >
6216    /// config file > default false. `AI_MEMORY_AUTONOMOUS_HOOKS=0` also
6217    /// honored for explicit-off.
6218    pub fn effective_autonomous_hooks(&self) -> bool {
6219        if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
6220            let v = v.trim().to_ascii_lowercase();
6221            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
6222                return true;
6223            }
6224            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
6225                return false;
6226            }
6227        }
6228        self.autonomous_hooks.unwrap_or(false)
6229    }
6230
6231    /// Whether to anonymize the default `agent_id` fallback (Task 1.2 #198).
6232    /// Precedence: `AI_MEMORY_ANONYMIZE=1` env var (truthy) > config file > default false.
6233    pub fn effective_anonymize_default(&self) -> bool {
6234        if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
6235            let v = v.trim().to_ascii_lowercase();
6236            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
6237                return true;
6238            }
6239            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
6240                return false;
6241            }
6242        }
6243        self.identity.as_ref().is_some_and(|i| i.anonymize_default)
6244    }
6245
6246    /// Resolve the [`LoggingConfig`] block, returning a default
6247    /// (disabled) instance when the config file omits it.
6248    pub fn effective_logging(&self) -> LoggingConfig {
6249        self.logging.clone().unwrap_or_default()
6250    }
6251
6252    /// Resolve the [`AuditConfig`] block, returning a default
6253    /// (disabled) instance when the config file omits it.
6254    pub fn effective_audit(&self) -> AuditConfig {
6255        self.audit.clone().unwrap_or_default()
6256    }
6257
6258    /// v0.7.0 I3 — resolve the [`TranscriptsConfig`] block, returning
6259    /// a default (no namespace overrides → compiled global defaults)
6260    /// instance when the config file omits it.
6261    #[must_use]
6262    pub fn effective_transcripts(&self) -> TranscriptsConfig {
6263        self.transcripts.clone().unwrap_or_default()
6264    }
6265
6266    /// Resolve the [`BootConfig`] block, returning a default
6267    /// (enabled, no redaction) instance when the config file omits
6268    /// it. v0.6.3.1 (PR-9h / issue #487 PR #497 req #73).
6269    pub fn effective_boot(&self) -> BootConfig {
6270        self.boot.clone().unwrap_or_default()
6271    }
6272
6273    /// Resolve URL for embedding model (falls back to `ollama_url`).
6274    ///
6275    /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6276    #[allow(deprecated)]
6277    pub fn effective_embed_url(&self) -> &str {
6278        self.embed_url
6279            .as_deref()
6280            .or(self.ollama_url.as_deref())
6281            .unwrap_or("http://localhost:11434")
6282    }
6283
6284    // ------------------------------------------------------------------
6285    // Canonical resolvers (#1146). Every LLM / embedder / reranker /
6286    // storage surface MUST consume the corresponding `Resolved*` shape
6287    // produced by these methods rather than reading raw config / env
6288    // / tier presets.
6289    //
6290    // Precedence (uniform across all four):
6291    //   CLI flag > AI_MEMORY_* env > config.toml section
6292    //            > legacy flat fields (Legacy source) > compiled default
6293    //
6294    // Resolvers are PURE (no network I/O). `resolve_llm` reads the
6295    // `api_key_file` content at call time if configured; perm checks
6296    // land in a follow-up commit and surface via `KeySource::Error`
6297    // without panicking.
6298    // ------------------------------------------------------------------
6299
6300    /// v0.7.x (#1146) — resolve the canonical LLM configuration.
6301    ///
6302    /// `cli_backend` / `cli_model` / `cli_base_url` carry CLI-flag
6303    /// overrides (pass `None` for `ai-memory mcp` / `ai-memory serve`
6304    /// which currently expose no CLI override; the CLI plumbing lands
6305    /// in a follow-up commit).
6306    ///
6307    /// DOC-6: this resolver intentionally reads the legacy flat
6308    /// fields as the lowest-precedence fallback layer (per the
6309    /// sectioned/v2 contract), so the `#[allow(deprecated)]`
6310    /// attribute is necessary here. External callers should pass
6311    /// CLI / env / `[llm]` section values and let this resolver
6312    /// reach for the legacy fields only when those are unset.
6313    #[must_use]
6314    #[allow(deprecated)]
6315    pub fn resolve_llm(
6316        &self,
6317        cli_backend: Option<&str>,
6318        cli_model: Option<&str>,
6319        cli_base_url: Option<&str>,
6320    ) -> ResolvedLlm {
6321        // ------- 1. backend selection ----------------------------------
6322        let env_backend = std::env::var("AI_MEMORY_LLM_BACKEND")
6323            .ok()
6324            .map(|s| s.trim().to_ascii_lowercase())
6325            .filter(|s| !s.is_empty());
6326        let cfg_backend = self
6327            .llm
6328            .as_ref()
6329            .and_then(|l| l.backend.as_ref())
6330            .map(|s| s.trim().to_ascii_lowercase())
6331            .filter(|s| !s.is_empty());
6332
6333        let (backend, source) = if let Some(b) = cli_backend.map(str::to_ascii_lowercase) {
6334            (b, ConfigSource::Cli)
6335        } else if let Some(b) = env_backend.clone() {
6336            (b, ConfigSource::Env)
6337        } else if let Some(b) = cfg_backend {
6338            (b, ConfigSource::Config)
6339        } else if self.llm_model.is_some() || self.ollama_url.is_some() {
6340            // Legacy flat fields imply Ollama.
6341            ("ollama".to_string(), ConfigSource::Legacy)
6342        } else {
6343            // Compiled default = tier preset (Ollama-native).
6344            ("ollama".to_string(), ConfigSource::CompiledDefault)
6345        };
6346
6347        // ------- 2. model selection ------------------------------------
6348        let model = cli_model
6349            .map(str::to_string)
6350            .filter(|s| !s.trim().is_empty())
6351            .or_else(|| {
6352                std::env::var("AI_MEMORY_LLM_MODEL")
6353                    .ok()
6354                    .filter(|s| !s.trim().is_empty())
6355            })
6356            .or_else(|| {
6357                self.llm
6358                    .as_ref()
6359                    .and_then(|l| l.model.clone())
6360                    .filter(|s| !s.trim().is_empty())
6361            })
6362            .or_else(|| self.llm_model.clone().filter(|s| !s.trim().is_empty()))
6363            .unwrap_or_else(|| backend_default_model(&backend).to_string());
6364
6365        // ------- 3. base_url selection ---------------------------------
6366        let base_url = cli_base_url
6367            .map(str::to_string)
6368            .filter(|s| !s.trim().is_empty())
6369            .or_else(|| {
6370                std::env::var("AI_MEMORY_LLM_BASE_URL")
6371                    .ok()
6372                    .filter(|s| !s.trim().is_empty())
6373            })
6374            .or_else(|| {
6375                self.llm
6376                    .as_ref()
6377                    .and_then(|l| l.base_url.clone())
6378                    .filter(|s| !s.trim().is_empty())
6379            })
6380            .or_else(|| {
6381                if backend == "ollama" {
6382                    self.ollama_url.clone()
6383                } else {
6384                    None
6385                }
6386            })
6387            .unwrap_or_else(|| backend_default_base_url(&backend).to_string());
6388
6389        // ------- 4. api_key selection ----------------------------------
6390        let (api_key, api_key_source) = resolve_api_key(&backend, self.llm.as_ref());
6391
6392        ResolvedLlm {
6393            backend,
6394            model,
6395            base_url,
6396            api_key,
6397            api_key_source,
6398            source,
6399        }
6400    }
6401
6402    /// v0.7.x (#1146) — resolve the `[llm.auto_tag]` fast-structured-
6403    /// output sibling. Fields fall back to [`Self::resolve_llm`] field-
6404    /// by-field; commonly only `model` is overridden (defaults to
6405    /// `gemma3:4b` per the L15 fast-structured-output policy).
6406    ///
6407    /// DOC-6: reads the legacy `auto_tag_model` field as the
6408    /// lowest-precedence fallback layer (`#[allow(deprecated)]`).
6409    #[must_use]
6410    #[allow(deprecated)]
6411    pub fn resolve_llm_auto_tag(&self) -> ResolvedLlm {
6412        let parent = self.resolve_llm(None, None, None);
6413        let sub = self.llm.as_ref().and_then(|l| l.auto_tag.as_ref());
6414
6415        let backend = sub
6416            .and_then(|s| s.backend.clone())
6417            .filter(|s| !s.trim().is_empty())
6418            .unwrap_or_else(|| parent.backend.clone());
6419
6420        let model = sub
6421            .and_then(|s| s.model.clone())
6422            .filter(|s| !s.trim().is_empty())
6423            .or_else(|| self.auto_tag_model.clone().filter(|s| !s.trim().is_empty()))
6424            .unwrap_or_else(|| {
6425                // L15 default: gemma3:4b for fast structured output,
6426                // regardless of parent backend.
6427                if backend == "ollama" {
6428                    "gemma3:4b".to_string()
6429                } else {
6430                    // For non-Ollama backends, use the parent model
6431                    // (no sane way to pick a "fast" model across vendors).
6432                    parent.model.clone()
6433                }
6434            });
6435
6436        let base_url = sub
6437            .and_then(|s| s.base_url.clone())
6438            .filter(|s| !s.trim().is_empty())
6439            .unwrap_or_else(|| {
6440                if backend == parent.backend {
6441                    parent.base_url.clone()
6442                } else {
6443                    backend_default_base_url(&backend).to_string()
6444                }
6445            });
6446
6447        // api_key: inherit from parent if backend matches, else fresh resolve.
6448        let (api_key, api_key_source) = if backend == parent.backend {
6449            (parent.api_key.clone(), parent.api_key_source.clone())
6450        } else {
6451            // Synthesise a transient LlmSection-like view from the sub-table
6452            // for fresh API-key resolution.
6453            let synthetic = sub.map(|s| LlmSection {
6454                backend: Some(backend.clone()),
6455                model: None,
6456                base_url: None,
6457                api_key_env: s.api_key_env.clone(),
6458                api_key_file: s.api_key_file.clone(),
6459                api_key: None,
6460                auto_tag: None,
6461            });
6462            resolve_api_key(&backend, synthetic.as_ref())
6463        };
6464
6465        ResolvedLlm {
6466            backend,
6467            model,
6468            base_url,
6469            api_key,
6470            api_key_source,
6471            source: parent.source,
6472        }
6473    }
6474
6475    /// v0.7.x (#1146) — resolve the canonical embedder configuration.
6476    ///
6477    /// #1598 — extended per-field precedence ladder:
6478    ///
6479    /// - `backend`: `AI_MEMORY_EMBED_BACKEND` env > `[embeddings].backend`
6480    ///   > compiled default (`ollama`).
6481    /// - `url`: `AI_MEMORY_EMBED_BASE_URL` env > `[embeddings].base_url`
6482    ///   > `[embeddings].url` > legacy `embed_url` > legacy `ollama_url`
6483    ///   > the backend alias's default base URL (API backends) > the
6484    ///   localhost Ollama default.
6485    /// - `model`: `AI_MEMORY_EMBED_MODEL` env > `[embeddings].model`
6486    ///   > legacy `embedding_model` > compiled default
6487    ///   (`nomic-embed-text-v1.5`); legacy aliases canonicalised.
6488    /// - `api_key`: [`resolve_embed_api_key`] ladder
6489    ///   (`AI_MEMORY_EMBED_API_KEY` > per-vendor alias env >
6490    ///   `[embeddings].api_key_env` > `[embeddings].api_key_file`).
6491    /// - `embedding_dim`: `[embeddings].dim` override >
6492    ///   [`canonical_embedding_dim`] table > `None`.
6493    ///
6494    /// DOC-6: reads the legacy `embed_url`/`embedding_model`/
6495    /// `ollama_url` fields as the lowest-precedence fallback layer.
6496    #[must_use]
6497    #[allow(deprecated)]
6498    pub fn resolve_embeddings(&self) -> ResolvedEmbeddings {
6499        let cfg = self.embeddings.as_ref();
6500
6501        let env_backend = std::env::var(ENV_EMBED_BACKEND)
6502            .ok()
6503            .map(|s| s.trim().to_ascii_lowercase())
6504            .filter(|s| !s.is_empty());
6505        let backend = env_backend
6506            .clone()
6507            .or_else(|| {
6508                cfg.and_then(|e| e.backend.as_ref())
6509                    .map(|s| s.trim().to_ascii_lowercase())
6510                    .filter(|s| !s.is_empty())
6511            })
6512            .unwrap_or_else(|| crate::llm::BACKEND_OLLAMA.to_string());
6513
6514        let url = std::env::var(ENV_EMBED_BASE_URL)
6515            .ok()
6516            .filter(|s| !s.trim().is_empty())
6517            .or_else(|| {
6518                cfg.and_then(|e| e.base_url.clone())
6519                    .filter(|s| !s.trim().is_empty())
6520            })
6521            .or_else(|| {
6522                cfg.and_then(|e| e.url.clone())
6523                    .filter(|s| !s.trim().is_empty())
6524            })
6525            .or_else(|| self.embed_url.clone().filter(|s| !s.trim().is_empty()))
6526            .or_else(|| self.ollama_url.clone().filter(|s| !s.trim().is_empty()))
6527            .or_else(|| {
6528                // #1598 — API backends default to the vendor's base URL
6529                // (declared once in llm.rs); `openai-compatible` has no
6530                // sane default and falls through.
6531                if is_api_embed_backend(&backend) {
6532                    crate::llm::default_base_url_for_alias(&backend).map(str::to_string)
6533                } else {
6534                    None
6535                }
6536            })
6537            .unwrap_or_else(|| crate::llm::DEFAULT_OLLAMA_URL.to_string());
6538
6539        let model = std::env::var(ENV_EMBED_MODEL)
6540            .ok()
6541            .filter(|s| !s.trim().is_empty())
6542            .or_else(|| {
6543                cfg.and_then(|e| e.model.clone())
6544                    .filter(|s| !s.trim().is_empty())
6545            })
6546            .or_else(|| {
6547                self.embedding_model
6548                    .clone()
6549                    .filter(|s| !s.trim().is_empty())
6550            })
6551            .map(canonicalise_embedding_model)
6552            .unwrap_or_else(|| DEFAULT_EMBED_MODEL.to_string());
6553
6554        let backfill_batch_env = std::env::var(ENV_EMBED_BACKFILL_BATCH)
6555            .ok()
6556            .and_then(|s| s.trim().parse::<u32>().ok());
6557        let backfill_batch_cfg = cfg.and_then(|e| e.backfill_batch);
6558        let backfill_batch_raw = backfill_batch_env.or(backfill_batch_cfg);
6559        let backfill_batch = match backfill_batch_raw {
6560            Some(n) if (1..=10000).contains(&n) => n,
6561            // #1649 — out-of-range values were silently swallowed while
6562            // the env-var table promised a warn-log (the sibling knob
6563            // AI_MEMORY_WEBHOOK_DISPATCH_CONCURRENCY already warns).
6564            Some(n) => {
6565                tracing::warn!(
6566                    "{ENV_EMBED_BACKFILL_BATCH}={n} outside 1..=10000 — falling back to default {DEFAULT_EMBED_BACKFILL_BATCH}"
6567                );
6568                DEFAULT_EMBED_BACKFILL_BATCH
6569            }
6570            None => DEFAULT_EMBED_BACKFILL_BATCH,
6571        };
6572
6573        let source = if env_backend.is_some() {
6574            ConfigSource::Env
6575        } else if cfg.is_some() {
6576            ConfigSource::Config
6577        } else if self.embed_url.is_some()
6578            || self.embedding_model.is_some()
6579            || self.ollama_url.is_some()
6580        {
6581            ConfigSource::Legacy
6582        } else {
6583            ConfigSource::CompiledDefault
6584        };
6585
6586        // v0.7.x (#1169) — derive the dim from the resolved model id
6587        // via the canonical lookup table. #1598 — the explicit
6588        // `[embeddings].dim` override wins (escape hatch for models
6589        // not in [`KNOWN_EMBEDDING_DIMS`]); non-positive overrides are
6590        // ignored. None when neither layer knows the dim; callers
6591        // (capabilities surface) fall back to the tier preset's
6592        // compiled dim.
6593        let embedding_dim = cfg
6594            .and_then(|e| e.dim)
6595            .filter(|d| *d > 0)
6596            .or_else(|| canonical_embedding_dim(&model));
6597
6598        // #1598 (fleet follow-up) — the EXPLICIT override alone also
6599        // becomes the wire `dimensions` request for OpenAI-compatible
6600        // backends (Matryoshka truncation; see
6601        // [`ResolvedEmbeddings::requested_dim`]). Deliberately NOT
6602        // populated from the table lookup — a table dim describes the
6603        // model's native output and must not be re-requested.
6604        let requested_dim = cfg.and_then(|e| e.dim).filter(|d| *d > 0);
6605
6606        // #1598 — embedding API key (None for ollama / keyless
6607        // self-hosted endpoints).
6608        let (api_key, key_source) = resolve_embed_api_key(&backend, cfg);
6609
6610        ResolvedEmbeddings {
6611            backend,
6612            url,
6613            model,
6614            backfill_batch,
6615            embedding_dim,
6616            requested_dim,
6617            api_key,
6618            key_source,
6619            source,
6620        }
6621    }
6622
6623    /// v0.7.x (#1146) — resolve the canonical reranker configuration.
6624    /// Folds the legacy `cross_encoder: Option<bool>` flag into the
6625    /// `enabled` field; `model` defaults to `ms-marco-MiniLM-L-6-v2`.
6626    ///
6627    /// DOC-6: reads the legacy `cross_encoder` field as the
6628    /// lowest-precedence fallback layer.
6629    #[must_use]
6630    #[allow(deprecated)]
6631    pub fn resolve_reranker(&self) -> ResolvedReranker {
6632        let cfg = self.reranker.as_ref();
6633
6634        let enabled = cfg
6635            .and_then(|r| r.enabled)
6636            .or(self.cross_encoder)
6637            // Default reranker-on for the autonomous tier; off otherwise.
6638            // Boot wires the actual tier-default at the resolver call
6639            // site (it's already keyed off `tier_config.cross_encoder`).
6640            .unwrap_or(false);
6641
6642        let model = cfg
6643            .and_then(|r| r.model.clone())
6644            .filter(|s| !s.trim().is_empty())
6645            .unwrap_or_else(|| "ms-marco-MiniLM-L-6-v2".to_string());
6646
6647        // #1604 — rerank input sequence cap, uniform ladder:
6648        // env > [reranker] section > compiled default. Zero,
6649        // unparseable, or above-model-ceiling values fall through.
6650        let admissible = |n: &usize| *n > 0 && *n <= crate::reranker::CROSS_ENCODER_MAX_SEQ;
6651        let max_seq_tokens = std::env::var(ENV_RERANK_MAX_SEQ)
6652            .ok()
6653            .and_then(|s| s.trim().parse::<usize>().ok())
6654            .filter(admissible)
6655            .or_else(|| cfg.and_then(|r| r.max_seq_tokens).filter(admissible))
6656            .unwrap_or(crate::reranker::RERANK_MAX_SEQ_DEFAULT);
6657
6658        let source = if cfg.is_some() {
6659            ConfigSource::Config
6660        } else if self.cross_encoder.is_some() {
6661            ConfigSource::Legacy
6662        } else {
6663            ConfigSource::CompiledDefault
6664        };
6665
6666        ResolvedReranker {
6667            enabled,
6668            model,
6669            max_seq_tokens,
6670            source,
6671        }
6672    }
6673
6674    /// v0.7.x (issue #1168) — bundle the three model-resolver outputs
6675    /// into a single [`ResolvedModels`] triple for the capabilities
6676    /// surface (MCP `memory_capabilities`, HTTP `GET /api/v1/capabilities`).
6677    ///
6678    /// Routes through the canonical [`Self::resolve_llm`],
6679    /// [`Self::resolve_embeddings`], and [`Self::resolve_reranker`]
6680    /// resolvers so the capabilities `models.*` block reflects the
6681    /// same resolved configuration the live LLM client / embedder /
6682    /// reranker were built from, NEVER the compiled tier preset.
6683    ///
6684    /// Pairs with [`ResolvedModels::from_tier_preset`] (back-compat
6685    /// constructor for tests that scaffold a `TierConfig` without an
6686    /// `AppConfig`).
6687    #[must_use]
6688    pub fn resolve_models(&self) -> ResolvedModels {
6689        ResolvedModels {
6690            llm: self.resolve_llm(None, None, None),
6691            embeddings: self.resolve_embeddings(),
6692            reranker: self.resolve_reranker(),
6693        }
6694    }
6695
6696    /// v0.7.x (#1146) — resolve the canonical storage configuration.
6697    ///
6698    /// DOC-6: reads the legacy `default_namespace`/`archive_on_gc`/
6699    /// `archive_max_days`/`max_memory_mb` fields as the
6700    /// lowest-precedence fallback layer.
6701    #[must_use]
6702    #[allow(deprecated)]
6703    pub fn resolve_storage(&self) -> ResolvedStorage {
6704        let cfg = self.storage.as_ref();
6705
6706        // #1590 — track WHICH layer supplied `default_namespace` so
6707        // write-path consumers can distinguish an explicit operator
6708        // choice from the compiled fallback (only the former overrides
6709        // the historical per-surface defaults).
6710        let section_ns = cfg
6711            .and_then(|s| s.default_namespace.clone())
6712            .filter(|s| !s.trim().is_empty());
6713        let legacy_ns = self
6714            .default_namespace
6715            .clone()
6716            .filter(|s| !s.trim().is_empty());
6717        let default_namespace_source = if section_ns.is_some() {
6718            ConfigSource::Config
6719        } else if legacy_ns.is_some() {
6720            ConfigSource::Legacy
6721        } else {
6722            ConfigSource::CompiledDefault
6723        };
6724        let default_namespace = section_ns
6725            .or(legacy_ns)
6726            .unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string());
6727
6728        let archive_on_gc = cfg
6729            .and_then(|s| s.archive_on_gc)
6730            .or(self.archive_on_gc)
6731            .unwrap_or(true);
6732
6733        let archive_max_days = cfg
6734            .and_then(|s| s.archive_max_days)
6735            .or(self.archive_max_days);
6736
6737        let max_memory_mb = cfg.and_then(|s| s.max_memory_mb).or(self.max_memory_mb);
6738
6739        // #1579 B7 — sqlite mmap size, uniform ladder:
6740        // env > [storage] section > compiled default. `0` is a
6741        // deliberate operator choice (disable mmap) so the filter
6742        // admits it; negative / unparseable values fall through.
6743        let db_mmap_size_bytes = std::env::var(ENV_DB_MMAP_SIZE)
6744            .ok()
6745            .and_then(|s| s.trim().parse::<i64>().ok())
6746            .filter(|n| *n >= 0)
6747            .or_else(|| cfg.and_then(|s| s.db_mmap_size_bytes).filter(|n| *n >= 0))
6748            .unwrap_or(crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES);
6749
6750        let source = if cfg.is_some() {
6751            ConfigSource::Config
6752        } else if self.default_namespace.is_some()
6753            || self.archive_on_gc.is_some()
6754            || self.archive_max_days.is_some()
6755            || self.max_memory_mb.is_some()
6756        {
6757            ConfigSource::Legacy
6758        } else {
6759            ConfigSource::CompiledDefault
6760        };
6761
6762        ResolvedStorage {
6763            default_namespace,
6764            archive_on_gc,
6765            archive_max_days,
6766            max_memory_mb,
6767            db_mmap_size_bytes,
6768            default_namespace_source,
6769            source,
6770        }
6771    }
6772
6773    /// v0.7.x — resolve the operator-tunable capacity limits.
6774    ///
6775    /// Precedence ladder per field (highest wins):
6776    /// `AI_MEMORY_MAX_*` env > `[limits]` section > compiled default.
6777    /// Non-positive values (≤ 0) at any layer are treated as "unset" so
6778    /// a stray `0` never silently disables writes — the next layer down
6779    /// is consulted instead. The compiled defaults are the named
6780    /// `crate::quotas::DEFAULT_MAX_*` constants and
6781    /// [`crate::handlers::MAX_BULK_SIZE`]; no numeric literals live in
6782    /// this resolver.
6783    #[must_use]
6784    pub fn resolve_limits(&self) -> ResolvedLimits {
6785        let cfg = self.limits.as_ref();
6786
6787        fn env_pos_i64(name: &str) -> Option<i64> {
6788            std::env::var(name)
6789                .ok()
6790                .and_then(|s| s.trim().parse::<i64>().ok())
6791                .filter(|n| *n > 0)
6792        }
6793        fn env_pos_usize(name: &str) -> Option<usize> {
6794            std::env::var(name)
6795                .ok()
6796                .and_then(|s| s.trim().parse::<usize>().ok())
6797                .filter(|n| *n > 0)
6798        }
6799
6800        let mem_env = env_pos_i64(ENV_MAX_MEMORIES_PER_DAY);
6801        let mem_cfg = cfg.and_then(|l| l.max_memories_per_day).filter(|n| *n > 0);
6802        let max_memories_per_day = mem_env
6803            .or(mem_cfg)
6804            .unwrap_or(crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY);
6805
6806        let bytes_env = env_pos_i64(ENV_MAX_STORAGE_BYTES);
6807        let bytes_cfg = cfg.and_then(|l| l.max_storage_bytes).filter(|n| *n > 0);
6808        let max_storage_bytes = bytes_env
6809            .or(bytes_cfg)
6810            .unwrap_or(crate::quotas::DEFAULT_MAX_STORAGE_BYTES);
6811
6812        let links_env = env_pos_i64(ENV_MAX_LINKS_PER_DAY);
6813        let links_cfg = cfg.and_then(|l| l.max_links_per_day).filter(|n| *n > 0);
6814        let max_links_per_day = links_env
6815            .or(links_cfg)
6816            .unwrap_or(crate::quotas::DEFAULT_MAX_LINKS_PER_DAY);
6817
6818        let page_env = env_pos_usize(ENV_MAX_PAGE_SIZE);
6819        let page_cfg = cfg.and_then(|l| l.max_page_size).filter(|n| *n > 0);
6820        let max_page_size = page_env
6821            .or(page_cfg)
6822            .unwrap_or(crate::handlers::MAX_BULK_SIZE);
6823
6824        let source = if mem_env.is_some()
6825            || bytes_env.is_some()
6826            || links_env.is_some()
6827            || page_env.is_some()
6828        {
6829            ConfigSource::Env
6830        } else if mem_cfg.is_some()
6831            || bytes_cfg.is_some()
6832            || links_cfg.is_some()
6833            || page_cfg.is_some()
6834        {
6835            ConfigSource::Config
6836        } else {
6837            ConfigSource::CompiledDefault
6838        };
6839
6840        ResolvedLimits {
6841            max_memories_per_day,
6842            max_storage_bytes,
6843            max_links_per_day,
6844            max_page_size,
6845            source,
6846        }
6847    }
6848
6849    /// Resolve the Postgres connection-pool sizing knobs into a
6850    /// [`crate::store::PoolConfig`] for the daemon's `build_store_handle`.
6851    ///
6852    /// Follows the uniform precedence ladder, per field:
6853    ///
6854    /// ```text
6855    /// AI_MEMORY_PG_POOL_MAX / _MIN / _ACQUIRE_TIMEOUT_SECS env
6856    ///   > top-level config.toml field
6857    ///   > compiled default (PoolConfig::default())
6858    /// ```
6859    ///
6860    /// Mirrors [`Self::resolve_limits`]: any non-positive or unparseable
6861    /// value is filtered so it falls through to the next layer (a stray
6862    /// `0` `max_connections` can never collapse the pool to unusable).
6863    #[cfg(feature = "sal")]
6864    #[must_use]
6865    pub fn resolve_pg_pool(&self) -> crate::store::PoolConfig {
6866        fn env_pos_u32(name: &str) -> Option<u32> {
6867            std::env::var(name)
6868                .ok()
6869                .and_then(|s| s.trim().parse::<u32>().ok())
6870                .filter(|n| *n > 0)
6871        }
6872        fn env_pos_u64(name: &str) -> Option<u64> {
6873            std::env::var(name)
6874                .ok()
6875                .and_then(|s| s.trim().parse::<u64>().ok())
6876                .filter(|n| *n > 0)
6877        }
6878
6879        let defaults = crate::store::PoolConfig::default();
6880
6881        let max_connections = env_pos_u32(ENV_PG_POOL_MAX)
6882            .or_else(|| self.postgres_pool_max_connections.filter(|n| *n > 0))
6883            .unwrap_or(defaults.max_connections);
6884
6885        let min_connections = env_pos_u32(ENV_PG_POOL_MIN)
6886            .or_else(|| self.postgres_pool_min_connections.filter(|n| *n > 0))
6887            .unwrap_or(defaults.min_connections);
6888
6889        let acquire_timeout_secs = env_pos_u64(ENV_PG_ACQUIRE_TIMEOUT_SECS)
6890            .or_else(|| self.postgres_acquire_timeout_secs.filter(|n| *n > 0))
6891            .unwrap_or(defaults.acquire_timeout_secs);
6892
6893        crate::store::PoolConfig {
6894            max_connections,
6895            min_connections,
6896            acquire_timeout_secs,
6897        }
6898    }
6899
6900    /// Write a default config file if one doesn't exist yet.
6901    pub fn write_default_if_missing() {
6902        let Some(path) = Self::config_path() else {
6903            return;
6904        };
6905        if path.exists() {
6906            return;
6907        }
6908        if let Some(parent) = path.parent() {
6909            let _ = std::fs::create_dir_all(parent);
6910        }
6911        let default_toml = r#"# ai-memory configuration
6912# See: https://github.com/alphaonedev/ai-memory-mcp
6913
6914# Feature tier: keyword, semantic, smart, autonomous
6915# tier = "semantic"
6916
6917# Path to SQLite database
6918# db = "~/.claude/ai-memory.db"
6919
6920# Ollama base URL (for smart/autonomous tiers)
6921# ollama_url = "http://localhost:11434"
6922
6923# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
6924# embedding_model = "mini_lm_l6_v2"
6925
6926# LLM model tag for Ollama
6927# llm_model = "gemma4:e2b"
6928
6929# Dedicated model for auto_tag (short structured output).
6930# Defaults to gemma3:4b. Reasoning-heavy features still use llm_model.
6931# auto_tag_model = "gemma3:4b"
6932
6933# Enable neural cross-encoder reranking (autonomous tier)
6934# cross_encoder = true
6935
6936# Default namespace for new memories
6937# default_namespace = "global"
6938
6939# Memory budget in MB (for auto tier selection)
6940# max_memory_mb = 4096
6941
6942# Archive expired memories before GC deletion (default: true)
6943# archive_on_gc = true
6944
6945# Postgres connection-pool sizing (postgres store only; sqlite ignores).
6946# Precedence per field: AI_MEMORY_PG_POOL_MAX / _MIN /
6947# _ACQUIRE_TIMEOUT_SECS env > these fields > compiled default.
6948# Non-positive / unparseable values fall through to the default.
6949# postgres_pool_max_connections = 16        # hard ceiling on open connections
6950# postgres_pool_min_connections = 2         # always-open warm-connection floor
6951# postgres_acquire_timeout_secs = 30        # acquire() wait before erroring (secs)
6952
6953# Per-tier TTL overrides (uncomment to customize)
6954# [ttl]
6955# short_ttl_secs = 21600        # 6 hours (default)
6956# mid_ttl_secs = 604800         # 7 days (default)
6957# long_ttl_secs = 0             # 0 = never expires (default)
6958# short_extend_secs = 3600      # +1h on access (default)
6959# mid_extend_secs = 86400       # +1d on access (default)
6960
6961# v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
6962# Default-OFF. Uncomment + set enabled = true to capture every
6963# `tracing::*` call site to a rotating on-disk log file. See
6964# `docs/security/audit-trail.md` §SIEM ingestion guide for Splunk /
6965# Datadog / Elastic / Loki recipes.
6966# [logging]
6967# enabled = false
6968# path = "~/.local/state/ai-memory/logs/"
6969# max_size_mb = 100
6970# max_files = 30
6971# retention_days = 90
6972# structured = false              # true = emit JSON lines for SIEM ingest
6973# level = "info"                  # tracing EnvFilter directive
6974# rotation = "daily"              # minutely | hourly | daily | never
6975
6976# v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF.
6977# When enabled, every memory mutation emits one hash-chained JSON
6978# line per event suitable for SOC2 / HIPAA / GDPR / FedRAMP evidence.
6979# `ai-memory audit verify` walks the chain; `ai-memory logs tail`
6980# streams events.
6981# [audit]
6982# enabled = false
6983# path = "~/.local/state/ai-memory/audit/"
6984# schema_version = 1
6985# redact_content = true            # v1 schema never emits content; reserved
6986# hash_chain = true
6987# attestation_cadence_minutes = 60
6988# append_only = true               # best-effort chflags(2) / FS_IOC_SETFLAGS
6989
6990# Compliance presets. Set `applied = true` and the documented retention
6991# / cadence values override the defaults above. See
6992# `docs/security/audit-trail.md` §Compliance.
6993# [audit.compliance.soc2]
6994# applied = false
6995# retention_days = 730
6996# redact_content = true
6997# attestation_cadence_minutes = 60
6998#
6999# [audit.compliance.hipaa]
7000# applied = false
7001# retention_days = 2190
7002# redact_content = true
7003# encrypt_at_rest = true           # pair with --features sqlcipher
7004#
7005# [audit.compliance.gdpr]
7006# applied = false
7007# retention_days = 1095
7008# redact_content = true
7009# pseudonymize_actors = true       # reserved for v0.7+
7010#
7011# [audit.compliance.fedramp]
7012# applied = false
7013# retention_days = 1095
7014# redact_content = true
7015# attestation_cadence_minutes = 30
7016
7017# v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy controls.
7018# Default-ON (omit the section entirely for the historical pre-v0.6.3.1
7019# behavior). Two knobs:
7020#
7021# - `enabled = false` silences `ai-memory boot` entirely: empty stdout,
7022#   empty stderr, exit 0. The SessionStart hook injects nothing. Use on
7023#   privacy-sensitive hosts where memory titles must never enter CI
7024#   logs. The env var `AI_MEMORY_BOOT_ENABLED=0` takes precedence over
7025#   this config (same precedence pattern as PR-5's log-dir resolution).
7026#
7027# - `redact_titles = true` keeps the manifest header but replaces row
7028#   `title` fields with `<redacted>` — useful for compliance contexts
7029#   that need the audit-trail signal of "boot ran with N memories"
7030#   without exposing memory subjects.
7031# [boot]
7032# enabled = true
7033# redact_titles = false
7034"#;
7035        let _ = std::fs::write(&path, default_toml);
7036    }
7037}
7038
7039// ---------------------------------------------------------------------------
7040// Tests
7041// ---------------------------------------------------------------------------
7042
7043#[cfg(test)]
7044#[allow(deprecated)] // DOC-6: tests intentionally exercise legacy AppConfig flat fields
7045mod tests {
7046    use super::*;
7047
7048    /// M9 — process-wide guard around every test that calls
7049    /// `std::env::set_var` / `std::env::remove_var`. Test binaries run
7050    /// in parallel by default (`cargo test --jobs N`); env mutation is
7051    /// process-global so two scenarios touching the same key race
7052    /// non-deterministically. Every test in this module that flips an
7053    /// env var MUST hold this mutex for the duration of its body.
7054    ///
7055    /// Poison-OK: a panicking scenario that drops the guard mid-mutation
7056    /// still hands the next caller a usable lock. Subsequent tests
7057    /// re-establish the env state they need on entry.
7058    fn env_var_lock() -> std::sync::MutexGuard<'static, ()> {
7059        use std::sync::{Mutex, OnceLock};
7060        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
7061        LOCK.get_or_init(|| Mutex::new(()))
7062            .lock()
7063            .unwrap_or_else(std::sync::PoisonError::into_inner)
7064    }
7065
7066    #[test]
7067    fn tier_roundtrip() {
7068        for tier in [
7069            FeatureTier::Keyword,
7070            FeatureTier::Semantic,
7071            FeatureTier::Smart,
7072            FeatureTier::Autonomous,
7073        ] {
7074            assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
7075        }
7076    }
7077
7078    #[test]
7079    fn budget_selection() {
7080        assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
7081        assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
7082        assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
7083        assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
7084        assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
7085        assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
7086        assert_eq!(
7087            FeatureTier::from_memory_budget(4096),
7088            FeatureTier::Autonomous
7089        );
7090        assert_eq!(
7091            FeatureTier::from_memory_budget(8192),
7092            FeatureTier::Autonomous
7093        );
7094    }
7095
7096    #[test]
7097    fn embedding_dimensions() {
7098        assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
7099        assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
7100    }
7101
7102    /// L2 fix — `AppConfig.embedding_model` is an `Option<String>` we
7103    /// must parse before handing it to `build_embedder`. This test
7104    /// pins the wire form (snake_case, matches serde rename_all),
7105    /// confirms case-insensitive + trim-tolerant parsing, and that
7106    /// garbage input produces an actionable Err rather than panicking.
7107    #[test]
7108    fn embedding_model_from_str() {
7109        use std::str::FromStr;
7110        assert_eq!(
7111            EmbeddingModel::from_str("mini_lm_l6_v2").unwrap(),
7112            EmbeddingModel::MiniLmL6V2
7113        );
7114        assert_eq!(
7115            EmbeddingModel::from_str("nomic_embed_v15").unwrap(),
7116            EmbeddingModel::NomicEmbedV15
7117        );
7118        // Case-insensitive: operators copy/paste from docs in any case.
7119        assert_eq!(
7120            EmbeddingModel::from_str("MINI_LM_L6_V2").unwrap(),
7121            EmbeddingModel::MiniLmL6V2
7122        );
7123        assert_eq!(
7124            EmbeddingModel::from_str("Nomic_Embed_V15").unwrap(),
7125            EmbeddingModel::NomicEmbedV15
7126        );
7127        // Trim whitespace — common TOML editing artifact.
7128        assert_eq!(
7129            EmbeddingModel::from_str("  mini_lm_l6_v2  ").unwrap(),
7130            EmbeddingModel::MiniLmL6V2
7131        );
7132        // Invalid input -> Err with a useful message naming the bad value.
7133        let err = EmbeddingModel::from_str("garbage").unwrap_err();
7134        assert!(err.contains("garbage"), "err message lost the input: {err}");
7135        assert!(
7136            err.contains("mini_lm_l6_v2") && err.contains("nomic_embed_v15"),
7137            "err message should list valid options: {err}"
7138        );
7139    }
7140
7141    /// #1521 — `from_canonical_id` must accept every form an operator
7142    /// might write in `[embeddings].model`: the snake wire form, the HF
7143    /// id (the `canonicalise_embedding_model` output), the unprefixed
7144    /// shortname, and the Ollama tag. This is what lets the sectioned
7145    /// config block drive the daemon embedder.
7146    #[test]
7147    fn embedding_model_from_canonical_id_accepts_all_forms() {
7148        // nomic family — snake, canonical HF id, Ollama tag, prefixed id.
7149        for id in [
7150            "nomic_embed_v15",
7151            "nomic-embed-text-v1.5",
7152            "nomic-embed-text",
7153            "nomic-ai/nomic-embed-text-v1.5",
7154        ] {
7155            assert_eq!(
7156                EmbeddingModel::from_canonical_id(id),
7157                Some(EmbeddingModel::NomicEmbedV15),
7158                "nomic alias {id:?} must resolve"
7159            );
7160        }
7161        // MiniLM family — snake, canonical HF id, shortname, Ollama tag.
7162        for id in [
7163            "mini_lm_l6_v2",
7164            "sentence-transformers/all-MiniLM-L6-v2",
7165            "all-MiniLM-L6-v2",
7166            "all-minilm",
7167        ] {
7168            assert_eq!(
7169                EmbeddingModel::from_canonical_id(id),
7170                Some(EmbeddingModel::MiniLmL6V2),
7171                "minilm alias {id:?} must resolve"
7172            );
7173        }
7174        // The canonicalised output of a legacy alias must round-trip.
7175        assert_eq!(
7176            EmbeddingModel::from_canonical_id(&canonicalise_embedding_model(
7177                "nomic_embed_v15".to_string()
7178            )),
7179            Some(EmbeddingModel::NomicEmbedV15)
7180        );
7181        // Case-insensitive + whitespace-trimmed.
7182        assert_eq!(
7183            EmbeddingModel::from_canonical_id("  NOMIC-EMBED-TEXT-V1.5  "),
7184            Some(EmbeddingModel::NomicEmbedV15)
7185        );
7186        // Models the 2-model daemon embedder cannot construct → None
7187        // (caller falls back to the tier preset), and empty → None.
7188        assert_eq!(EmbeddingModel::from_canonical_id("bge-large-en"), None);
7189        assert_eq!(EmbeddingModel::from_canonical_id("mxbai-embed-large"), None);
7190        assert_eq!(EmbeddingModel::from_canonical_id(""), None);
7191        assert_eq!(EmbeddingModel::from_canonical_id("   "), None);
7192    }
7193
7194    #[test]
7195    fn autonomous_has_cross_encoder() {
7196        let cfg = FeatureTier::Autonomous.config();
7197        assert!(cfg.cross_encoder);
7198        let caps = cfg.capabilities();
7199        assert!(caps.features.cross_encoder_reranking);
7200        // v0.7.0 recursive-learning (issue #655): Tasks 1-6 shipped
7201        // the primitive, so the planned-feature object is now
7202        // `planned=false, enabled=true, version="v0.7.0"`. The
7203        // pre-v0.6.3.1 honesty contract still uses the
7204        // `PlannedFeature` shape so the v1 bool projection
7205        // collapses cleanly back to `true`.
7206        assert!(!caps.features.memory_reflection.planned);
7207        assert!(caps.features.memory_reflection.enabled);
7208        assert_eq!(caps.features.memory_reflection.version, "v0.7.0");
7209    }
7210
7211    #[test]
7212    fn keyword_has_no_models() {
7213        let cfg = FeatureTier::Keyword.config();
7214        assert!(cfg.embedding_model.is_none());
7215        assert!(cfg.llm_model.is_none());
7216        assert!(!cfg.cross_encoder);
7217        assert_eq!(cfg.max_memory_mb, 0);
7218    }
7219
7220    #[test]
7221    fn capabilities_serialize() {
7222        let caps = FeatureTier::Smart.config().capabilities();
7223        let json = serde_json::to_string_pretty(&caps).unwrap();
7224        assert!(json.contains("\"tier\": \"smart\""));
7225        assert!(json.contains("nomic"));
7226        // The smart tier surfaces the provider-agnostic compiled default
7227        // model tag — asserted against the single source of truth, not a
7228        // copied literal, so no vendor/model string is pinned in the test.
7229        assert!(json.contains(default_tier_llm_model()));
7230    }
7231
7232    /// v0.6.3.1 (capabilities schema v2, P1 honesty patch).
7233    /// Round-trip the new struct through serde_json and assert the v2
7234    /// honesty contract: dropped fields absent, planned-feature blocks
7235    /// shaped correctly, runtime-state defaults conservative.
7236    #[test]
7237    fn capabilities_v2_zero_state_round_trip() {
7238        let _gate = lock_permissions_mode_for_test();
7239        // K3 default is `advisory` — clear any override that a
7240        // sibling test might have left behind so the
7241        // `permissions.mode` field reflects the documented zero-state.
7242        clear_permissions_mode_override_for_test();
7243        let caps = FeatureTier::Keyword.config().capabilities();
7244        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7245
7246        assert_eq!(val["schema_version"], "2");
7247
7248        // permissions zero-state: mode="advisory" (was "ask" in v1),
7249        // active_rules=0. `rule_summary` dropped from v2.
7250        assert_eq!(val["permissions"]["mode"], "advisory");
7251        assert_eq!(val["permissions"]["active_rules"], 0);
7252        assert!(
7253            val["permissions"].get("rule_summary").is_none(),
7254            "v2 honesty patch drops `permissions.rule_summary` (no per-rule serializer)"
7255        );
7256        // v0.6.3.1 (P4, audit G1): inheritance posture surfaced.
7257        assert_eq!(val["permissions"]["inheritance"], "enforced");
7258
7259        // hooks zero-state: 0 registered. `by_event` dropped from v2.
7260        assert_eq!(val["hooks"]["registered_count"], 0);
7261        assert!(
7262            val["hooks"].get("by_event").is_none(),
7263            "v2 honesty patch drops `hooks.by_event` (no event registry)"
7264        );
7265
7266        // hooks zero-state: 0 registered, by_event dropped (P1 honesty)
7267        assert_eq!(val["hooks"]["registered_count"], 0);
7268        assert!(
7269            val["hooks"].get("by_event").is_none(),
7270            "v2 drops hooks.by_event (no event registry)"
7271        );
7272        // P5 (G9): webhook_events must always surface the canonical
7273        // lifecycle events so integrators can pin a subscribe filter
7274        // against them.
7275        //
7276        // v0.7.0 K4 — `approval_requested` joined the list.
7277        // v0.7 J4 / G14 — `memory_link_invalidated` also joined.
7278        // Total: seven canonical event types.
7279        let events = val["hooks"]["webhook_events"].as_array().unwrap();
7280        assert_eq!(events.len(), 7);
7281        for expected in [
7282            "memory_store",
7283            "memory_promote",
7284            "memory_delete",
7285            "memory_link_created",
7286            "memory_link_invalidated",
7287            "memory_consolidated",
7288            "approval_requested",
7289        ] {
7290            assert!(
7291                events.iter().any(|v| v.as_str() == Some(expected)),
7292                "webhook_events missing {expected}"
7293            );
7294        }
7295
7296        // compaction zero-state: planned, not enabled, optional fields omitted
7297        assert_eq!(val["compaction"]["planned"], true);
7298        assert_eq!(val["compaction"]["enabled"], false);
7299        assert_eq!(val["compaction"]["version"], "v0.8+");
7300        assert!(
7301            val["compaction"].get("interval_minutes").is_none(),
7302            "Option::None values must be skipped in serialization"
7303        );
7304        assert!(val["compaction"].get("last_run_at").is_none());
7305        assert!(val["compaction"].get("last_run_stats").is_none());
7306
7307        // approval zero-state: 0 pending. `subscribers` and
7308        // `default_timeout_seconds` dropped from v2.
7309        assert_eq!(val["approval"]["pending_requests"], 0);
7310        assert!(
7311            val["approval"].get("subscribers").is_none(),
7312            "v2 honesty patch drops `approval.subscribers` (no subscription API)"
7313        );
7314        assert!(
7315            val["approval"].get("default_timeout_seconds").is_none(),
7316            "v2 honesty patch drops `approval.default_timeout_seconds` (no sweeper)"
7317        );
7318
7319        // v0.7.0 #1324 — substrate ships at v0.7.0; capability flag
7320        // reads `planned: false, enabled: false` at zero-state (no rows
7321        // in `memory_transcripts`, no operator-wired R5 hook yet). The
7322        // live MCP / HTTP overlay flips `enabled: true` when the
7323        // transcripts row count is non-zero.
7324        assert_eq!(val["transcripts"]["planned"], false);
7325        assert_eq!(val["transcripts"]["enabled"], false);
7326        assert_eq!(val["transcripts"]["version"], env!("CARGO_PKG_VERSION"));
7327
7328        // memory_reflection: planned-feature object (was bool).
7329        // v0.7.0 recursive-learning (issue #655) Tasks 1-6 shipped the
7330        // primitive, so the flag is `planned=false, enabled=true,
7331        // version="v0.7.0"`.
7332        assert_eq!(val["features"]["memory_reflection"]["planned"], false);
7333        assert_eq!(val["features"]["memory_reflection"]["enabled"], true);
7334        assert_eq!(val["features"]["memory_reflection"]["version"], "v0.7.0");
7335
7336        // Runtime-state defaults are conservative — they get overlaid
7337        // at the handler boundary based on the live embedder + reranker
7338        // handles. With no overlays, the keyword-tier daemon reports
7339        // `disabled` / `off`.
7340        assert_eq!(val["features"]["recall_mode_active"], "disabled");
7341        assert_eq!(val["features"]["reranker_active"], "off");
7342
7343        // v0.7 J1 — kg_backend zero-state: no SAL adapter wired yet,
7344        // so the field is None and elided from the JSON wire. Older
7345        // clients that don't know the field round-trip cleanly.
7346        assert!(
7347            val.get("kg_backend").is_none(),
7348            "kg_backend must be skipped from JSON when None (pre-J2 zero-state)"
7349        );
7350
7351        // Round-trip back to a typed Capabilities and confirm field
7352        // identity (proves Deserialize works for all reshaped structs).
7353        let restored: Capabilities = serde_json::from_value(val).unwrap();
7354        assert_eq!(restored.schema_version, "2");
7355        assert_eq!(restored.permissions.mode, "advisory");
7356        assert!(restored.compaction.status.planned);
7357        // v0.7.0 #1324 — transcripts substrate ships at v0.7.0; the
7358        // capability flag was `planned: true` pre-#1324 (mis-advertised
7359        // the substrate as roadmap-only). Round-trip now pins
7360        // `planned: false`.
7361        assert!(!restored.transcripts.status.planned);
7362        assert_eq!(restored.features.recall_mode_active, RecallMode::Disabled);
7363        assert_eq!(restored.features.reranker_active, RerankerMode::Off);
7364        assert!(restored.kg_backend.is_none());
7365    }
7366
7367    /// v0.7 J1 — when a SAL adapter populates `kg_backend`, the wire
7368    /// shape must serialise the literal snake-case tag and round-trip
7369    /// cleanly. Operators read this through `ai-memory doctor` and
7370    /// `memory_capabilities` to verify which traversal path their
7371    /// daemon actually runs.
7372    #[test]
7373    fn capabilities_kg_backend_serialises_when_set() {
7374        let mut caps = FeatureTier::Keyword.config().capabilities();
7375        caps.kg_backend = Some("age".to_string());
7376        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7377        assert_eq!(val["kg_backend"], "age");
7378
7379        caps.kg_backend = Some("cte".to_string());
7380        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7381        assert_eq!(val["kg_backend"], "cte");
7382
7383        // Round-trip the populated field for Deserialize coverage.
7384        let restored: Capabilities = serde_json::from_value(val).unwrap();
7385        assert_eq!(restored.kg_backend.as_deref(), Some("cte"));
7386    }
7387
7388    /// P1 honesty patch: legacy v1 projection preserves the old shape
7389    /// for clients that opt in via `Accept-Capabilities: v1`.
7390    #[test]
7391    fn capabilities_v1_projection_preserves_legacy_shape() {
7392        let caps = FeatureTier::Autonomous.config().capabilities();
7393        let v1 = caps.to_v1();
7394        let val: serde_json::Value = serde_json::to_value(&v1).unwrap();
7395
7396        // v1: no schema_version, no v2-only blocks
7397        assert!(
7398            val.get("schema_version").is_none(),
7399            "v1 has no schema_version"
7400        );
7401        assert!(
7402            val.get("permissions").is_none(),
7403            "v1 has no permissions block"
7404        );
7405        assert!(val.get("hooks").is_none());
7406        assert!(val.get("compaction").is_none());
7407        assert!(val.get("approval").is_none());
7408        assert!(val.get("transcripts").is_none());
7409
7410        // v1 keeps the four legacy top-level keys
7411        assert!(val["tier"].is_string());
7412        assert!(val["version"].is_string());
7413        assert!(val["features"].is_object());
7414        assert!(val["models"].is_object());
7415
7416        // v1 features.memory_reflection collapses to a bool. v0.7.0
7417        // recursive-learning (issue #655) Tasks 1-6 shipped the
7418        // primitive, so the v2 planned-feature object now has
7419        // `enabled = true` and the v1 bool projection is `true`.
7420        assert!(val["features"]["memory_reflection"].is_boolean());
7421        assert_eq!(val["features"]["memory_reflection"], true);
7422
7423        // v1 features carry no recall_mode_active / reranker_active
7424        assert!(val["features"].get("recall_mode_active").is_none());
7425        assert!(val["features"].get("reranker_active").is_none());
7426    }
7427
7428    #[test]
7429    fn config_default_is_empty() {
7430        let cfg = AppConfig::default();
7431        assert!(cfg.tier.is_none());
7432        assert!(cfg.db.is_none());
7433        assert!(cfg.ollama_url.is_none());
7434    }
7435
7436    #[test]
7437    fn config_parse_toml() {
7438        let toml_str = r#"
7439            tier = "smart"
7440            db = "/tmp/test.db"
7441            ollama_url = "http://localhost:11434"
7442            cross_encoder = true
7443        "#;
7444        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
7445        assert_eq!(cfg.tier.as_deref(), Some("smart"));
7446        assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
7447        assert!(cfg.cross_encoder.unwrap());
7448    }
7449
7450    #[test]
7451    fn resolved_ttl_defaults_match_hardcoded() {
7452        let resolved = ResolvedTtl::default();
7453        assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR));
7454        assert_eq!(resolved.mid_ttl_secs, Some(crate::SECS_PER_WEEK));
7455        assert_eq!(resolved.long_ttl_secs, None);
7456        assert_eq!(resolved.short_extend_secs, crate::SECS_PER_HOUR);
7457        assert_eq!(resolved.mid_extend_secs, crate::SECS_PER_DAY);
7458    }
7459
7460    #[test]
7461    fn resolved_ttl_from_partial_config() {
7462        let cfg = TtlConfig {
7463            mid_ttl_secs: Some(90 * crate::SECS_PER_DAY), // ~3 months
7464            ..Default::default()
7465        };
7466        let resolved = ResolvedTtl::from_config(Some(&cfg));
7467        assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR)); // unchanged
7468        assert_eq!(resolved.mid_ttl_secs, Some(90 * crate::SECS_PER_DAY)); // overridden
7469        assert_eq!(resolved.long_ttl_secs, None); // unchanged
7470    }
7471
7472    #[test]
7473    fn resolved_ttl_zero_means_no_expiry() {
7474        let cfg = TtlConfig {
7475            short_ttl_secs: Some(0),
7476            mid_ttl_secs: Some(0),
7477            ..Default::default()
7478        };
7479        let resolved = ResolvedTtl::from_config(Some(&cfg));
7480        assert_eq!(resolved.short_ttl_secs, None); // 0 → no expiry
7481        assert_eq!(resolved.mid_ttl_secs, None);
7482    }
7483
7484    #[test]
7485    fn resolved_ttl_clamps_overflow() {
7486        let cfg = TtlConfig {
7487            mid_ttl_secs: Some(i64::MAX),
7488            short_extend_secs: Some(-crate::SECS_PER_HOUR),
7489            ..Default::default()
7490        };
7491        let resolved = ResolvedTtl::from_config(Some(&cfg));
7492        // i64::MAX should be clamped to MAX_TTL_SECS (10 years)
7493        assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
7494        // negative extend should be clamped to 0
7495        assert_eq!(resolved.short_extend_secs, 0);
7496    }
7497
7498    #[test]
7499    fn ttl_config_parse_toml() {
7500        let toml_str = r#"
7501            tier = "semantic"
7502            archive_on_gc = false
7503            [ttl]
7504            mid_ttl_secs = 7776000
7505            short_extend_secs = 7200
7506        "#;
7507        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
7508        assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
7509        assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
7510        assert!(!cfg.effective_archive_on_gc());
7511    }
7512
7513    #[test]
7514    fn resolved_ttl_tier_methods() {
7515        let resolved = ResolvedTtl::default();
7516        assert_eq!(
7517            resolved.ttl_for_tier(&Tier::Short),
7518            Some(6 * crate::SECS_PER_HOUR)
7519        );
7520        assert_eq!(
7521            resolved.ttl_for_tier(&Tier::Mid),
7522            Some(crate::SECS_PER_WEEK)
7523        );
7524        assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
7525        assert_eq!(
7526            resolved.extend_for_tier(&Tier::Short),
7527            Some(crate::SECS_PER_HOUR)
7528        );
7529        assert_eq!(
7530            resolved.extend_for_tier(&Tier::Mid),
7531            Some(crate::SECS_PER_DAY)
7532        );
7533        assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
7534    }
7535
7536    #[test]
7537    fn config_effective_tier() {
7538        let cfg = AppConfig {
7539            tier: Some("smart".to_string()),
7540            ..Default::default()
7541        };
7542        // CLI override wins
7543        assert_eq!(
7544            cfg.effective_tier(Some("autonomous")),
7545            FeatureTier::Autonomous
7546        );
7547        // Config value used when no CLI
7548        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
7549    }
7550
7551    // --- v0.6.0.0 recall scoring (time-decay half-life) ---
7552
7553    #[test]
7554    fn scoring_defaults_match_spec() {
7555        let s = ResolvedScoring::default();
7556        assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
7557        assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
7558        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
7559        assert!(!s.legacy_scoring);
7560    }
7561
7562    #[test]
7563    fn scoring_from_config_overrides() {
7564        let cfg = RecallScoringConfig {
7565            half_life_days_short: Some(3.5),
7566            half_life_days_mid: Some(14.0),
7567            half_life_days_long: Some(730.0),
7568            legacy_scoring: false,
7569        };
7570        let s = ResolvedScoring::from_config(Some(&cfg));
7571        assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
7572        assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
7573        assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
7574    }
7575
7576    #[test]
7577    fn scoring_clamps_out_of_range() {
7578        let cfg = RecallScoringConfig {
7579            half_life_days_short: Some(-10.0),
7580            half_life_days_mid: Some(0.0),
7581            half_life_days_long: Some(1_000_000.0),
7582            legacy_scoring: false,
7583        };
7584        let s = ResolvedScoring::from_config(Some(&cfg));
7585        assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
7586        assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
7587        assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
7588    }
7589
7590    #[test]
7591    fn scoring_decay_at_half_life_is_half() {
7592        let s = ResolvedScoring::default();
7593        // Short tier half-life is 7 days → at age=7d, decay=0.5
7594        let d = s.decay_multiplier(&Tier::Short, 7.0);
7595        assert!((d - 0.5).abs() < 1e-9);
7596        let d = s.decay_multiplier(&Tier::Mid, 30.0);
7597        assert!((d - 0.5).abs() < 1e-9);
7598        let d = s.decay_multiplier(&Tier::Long, 365.0);
7599        assert!((d - 0.5).abs() < 1e-9);
7600    }
7601
7602    #[test]
7603    fn scoring_decay_monotonic() {
7604        let s = ResolvedScoring::default();
7605        let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
7606        let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
7607        // Older memories decay more (lower multiplier).
7608        assert!(d_new > d_old);
7609        assert!(d_new < 1.0);
7610        assert!(d_old > 0.0);
7611    }
7612
7613    #[test]
7614    fn scoring_decay_zero_age_is_one() {
7615        let s = ResolvedScoring::default();
7616        assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
7617        // Negative ages (clock skew, future timestamps) are also treated as fresh.
7618        assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
7619    }
7620
7621    #[test]
7622    fn scoring_legacy_disables_decay() {
7623        let cfg = RecallScoringConfig {
7624            legacy_scoring: true,
7625            ..Default::default()
7626        };
7627        let s = ResolvedScoring::from_config(Some(&cfg));
7628        // No decay regardless of age.
7629        assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
7630        assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
7631        assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
7632    }
7633
7634    #[test]
7635    fn effective_scoring_on_empty_config() {
7636        let cfg = AppConfig::default();
7637        let s = cfg.effective_scoring();
7638        assert_eq!(s.half_life_days_short, 7.0);
7639        assert!(!s.legacy_scoring);
7640    }
7641
7642    #[test]
7643    fn scoring_roundtrip_through_toml() {
7644        let toml_src = r"
7645[scoring]
7646half_life_days_short = 5.0
7647half_life_days_mid = 25.0
7648legacy_scoring = false
7649";
7650        let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
7651        let s = cfg.effective_scoring();
7652        assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
7653        assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
7654        // Unset long defaults.
7655        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
7656    }
7657
7658    // ---- Wave 3 (Closer T) tests for uncovered effective_* helpers
7659    // and write_default_if_missing. ----
7660
7661    #[test]
7662    fn effective_tier_cli_overrides_config() {
7663        let cfg = AppConfig {
7664            tier: Some("smart".to_string()),
7665            ..AppConfig::default()
7666        };
7667        // CLI flag wins over config.
7668        assert_eq!(
7669            cfg.effective_tier(Some("autonomous")),
7670            FeatureTier::Autonomous
7671        );
7672        // No CLI flag → config used.
7673        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
7674    }
7675
7676    #[test]
7677    fn effective_tier_unknown_falls_back_to_semantic() {
7678        let cfg = AppConfig::default();
7679        assert_eq!(
7680            cfg.effective_tier(Some("invalid-tier")),
7681            FeatureTier::Semantic
7682        );
7683        // No CLI, no config → default semantic.
7684        assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
7685    }
7686
7687    // ---- v0.6.4-001 — `effective_profile` resolution tests.
7688    //
7689    // Resolution order: CLI/env > [mcp].profile config > "core" default.
7690    // Clap merges CLI and env into the same `Option<&str>` before this
7691    // function sees it, so the function only needs to test "explicit
7692    // override > config > default". Env-var precedence over CLI cannot
7693    // happen by design (clap precedence is CLI > env), so it is not
7694    // tested at this layer.
7695
7696    #[test]
7697    fn effective_profile_cli_or_env_overrides_config() {
7698        let cfg = AppConfig {
7699            mcp: Some(McpConfig {
7700                profile: Some("graph".to_string()),
7701                allowlist: None,
7702                ..McpConfig::default()
7703            }),
7704            ..AppConfig::default()
7705        };
7706        // CLI/env value beats the config value.
7707        assert_eq!(
7708            cfg.effective_profile(Some("admin")).unwrap(),
7709            crate::profile::Profile::admin()
7710        );
7711        // No CLI/env → config used.
7712        assert_eq!(
7713            cfg.effective_profile(None).unwrap(),
7714            crate::profile::Profile::graph()
7715        );
7716    }
7717
7718    #[test]
7719    fn effective_profile_falls_back_to_core_default() {
7720        let cfg = AppConfig::default();
7721        // No mcp config, no CLI → core (the v0.6.4 default flip).
7722        assert_eq!(
7723            cfg.effective_profile(None).unwrap(),
7724            crate::profile::Profile::core()
7725        );
7726    }
7727
7728    #[test]
7729    fn effective_profile_surfaces_parse_error_for_unknown_family() {
7730        let cfg = AppConfig::default();
7731        assert!(matches!(
7732            cfg.effective_profile(Some("xyz")),
7733            Err(crate::profile::ProfileParseError::UnknownFamily(_))
7734        ));
7735    }
7736
7737    #[test]
7738    fn effective_profile_surfaces_parse_error_for_mixed_case() {
7739        let cfg = AppConfig::default();
7740        assert!(matches!(
7741            cfg.effective_profile(Some("Core")),
7742            Err(crate::profile::ProfileParseError::CaseMismatch(_))
7743        ));
7744    }
7745
7746    // ---- v0.6.4-008 — `[mcp.allowlist]` resolution tests.
7747
7748    fn allowlist_table(rows: &[(&str, &[&str])]) -> McpConfig {
7749        let mut map = std::collections::HashMap::new();
7750        for (k, v) in rows {
7751            map.insert(
7752                (*k).to_string(),
7753                v.iter().map(|s| (*s).to_string()).collect(),
7754            );
7755        }
7756        McpConfig {
7757            profile: None,
7758            allowlist: Some(map),
7759            ..McpConfig::default()
7760        }
7761    }
7762
7763    #[test]
7764    fn allowlist_disabled_when_table_absent() {
7765        let cfg = McpConfig::default();
7766        assert_eq!(
7767            cfg.allowlist_decision(Some("alice"), "graph"),
7768            AllowlistDecision::Disabled
7769        );
7770    }
7771
7772    #[test]
7773    fn allowlist_disabled_when_table_empty() {
7774        let cfg = McpConfig {
7775            profile: None,
7776            allowlist: Some(std::collections::HashMap::new()),
7777            ..McpConfig::default()
7778        };
7779        assert_eq!(
7780            cfg.allowlist_decision(Some("alice"), "graph"),
7781            AllowlistDecision::Disabled
7782        );
7783    }
7784
7785    #[test]
7786    fn allowlist_exact_match_grants_or_denies_per_family_set() {
7787        let cfg = allowlist_table(&[("alice", &["core", "graph"]), ("*", &["core"])]);
7788        assert_eq!(
7789            cfg.allowlist_decision(Some("alice"), "graph"),
7790            AllowlistDecision::Allow
7791        );
7792        assert_eq!(
7793            cfg.allowlist_decision(Some("alice"), "power"),
7794            AllowlistDecision::Deny
7795        );
7796    }
7797
7798    #[test]
7799    fn allowlist_full_grants_every_family() {
7800        let cfg = allowlist_table(&[("bob", &["full"])]);
7801        assert_eq!(
7802            cfg.allowlist_decision(Some("bob"), "graph"),
7803            AllowlistDecision::Allow
7804        );
7805        assert_eq!(
7806            cfg.allowlist_decision(Some("bob"), "archive"),
7807            AllowlistDecision::Allow
7808        );
7809    }
7810
7811    #[test]
7812    fn allowlist_wildcard_default_for_unknown_agents() {
7813        let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
7814        assert_eq!(
7815            cfg.allowlist_decision(Some("eve"), "core"),
7816            AllowlistDecision::Allow
7817        );
7818        assert_eq!(
7819            cfg.allowlist_decision(Some("eve"), "graph"),
7820            AllowlistDecision::Deny
7821        );
7822    }
7823
7824    #[test]
7825    fn allowlist_default_deny_when_no_wildcard() {
7826        let cfg = allowlist_table(&[("alice", &["full"])]);
7827        assert_eq!(
7828            cfg.allowlist_decision(Some("eve"), "core"),
7829            AllowlistDecision::Deny
7830        );
7831    }
7832
7833    #[test]
7834    fn allowlist_longest_prefix_match_wins() {
7835        let cfg = allowlist_table(&[
7836            ("ai:", &["core"]),
7837            ("ai:claude-code", &["full"]),
7838            ("*", &["core"]),
7839        ]);
7840        // The longer prefix takes precedence over the shorter one.
7841        assert_eq!(
7842            cfg.allowlist_decision(Some("ai:claude-code@host"), "graph"),
7843            AllowlistDecision::Allow
7844        );
7845        // Shorter prefix still works for other ai:* agents.
7846        assert_eq!(
7847            cfg.allowlist_decision(Some("ai:codex@host"), "graph"),
7848            AllowlistDecision::Deny
7849        );
7850    }
7851
7852    #[test]
7853    fn allowlist_no_agent_id_uses_wildcard() {
7854        // Tier-1 / anonymous: no agent_id provided → only the wildcard
7855        // rule is consulted.
7856        let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
7857        assert_eq!(
7858            cfg.allowlist_decision(None, "core"),
7859            AllowlistDecision::Allow
7860        );
7861        assert_eq!(
7862            cfg.allowlist_decision(None, "graph"),
7863            AllowlistDecision::Deny
7864        );
7865    }
7866
7867    #[test]
7868    fn effective_db_cli_path_wins_when_non_default() {
7869        let cfg = AppConfig {
7870            db: Some("/from/config.db".to_string()),
7871            ..AppConfig::default()
7872        };
7873        let cli_path = Path::new("/from/cli.db");
7874        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
7875    }
7876
7877    #[test]
7878    fn effective_db_falls_back_to_config_when_cli_default() {
7879        let cfg = AppConfig {
7880            db: Some("/from/config.db".to_string()),
7881            ..AppConfig::default()
7882        };
7883        // The CLI default is "ai-memory.db" — config wins for that case.
7884        assert_eq!(
7885            cfg.effective_db(Path::new("ai-memory.db")),
7886            PathBuf::from("/from/config.db")
7887        );
7888    }
7889
7890    #[test]
7891    fn effective_db_falls_back_to_cli_when_no_config() {
7892        let cfg = AppConfig::default();
7893        let cli_path = Path::new("ai-memory.db");
7894        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
7895    }
7896
7897    #[test]
7898    fn effective_db_expands_tilde_against_home() {
7899        // #507: `db = "~/.claude/ai-memory.db"` must resolve to $HOME-based
7900        // path rather than the literal four-char prefix. Use env_var_lock
7901        // because HOME mutation is process-global.
7902        let _g = env_var_lock();
7903        let prev_home = std::env::var("HOME").ok();
7904        // SAFETY: serialized via env_var_lock; restored below.
7905        unsafe { std::env::set_var("HOME", "/expanded/home") };
7906        let cfg = AppConfig {
7907            db: Some("~/.claude/ai-memory.db".to_string()),
7908            ..AppConfig::default()
7909        };
7910        assert_eq!(
7911            cfg.effective_db(Path::new("ai-memory.db")),
7912            PathBuf::from("/expanded/home/.claude/ai-memory.db")
7913        );
7914        // Bare `~` resolves to $HOME itself.
7915        let cfg_bare = AppConfig {
7916            db: Some("~".to_string()),
7917            ..AppConfig::default()
7918        };
7919        assert_eq!(
7920            cfg_bare.effective_db(Path::new("ai-memory.db")),
7921            PathBuf::from("/expanded/home")
7922        );
7923        // Restore.
7924        match prev_home {
7925            Some(h) => unsafe { std::env::set_var("HOME", h) },
7926            None => unsafe { std::env::remove_var("HOME") },
7927        }
7928    }
7929
7930    #[test]
7931    fn effective_ollama_url_default_when_unset() {
7932        let cfg = AppConfig::default();
7933        assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
7934    }
7935
7936    #[test]
7937    fn effective_ollama_url_uses_configured_value() {
7938        let cfg = AppConfig {
7939            ollama_url: Some("http://my-host:9999".to_string()),
7940            ..AppConfig::default()
7941        };
7942        assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
7943    }
7944
7945    #[test]
7946    fn effective_embed_url_falls_back_to_ollama_url() {
7947        let cfg = AppConfig {
7948            ollama_url: Some("http://ollama:11434".to_string()),
7949            ..AppConfig::default()
7950        };
7951        // No embed_url → fall back to ollama_url.
7952        assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
7953    }
7954
7955    #[test]
7956    fn effective_embed_url_uses_dedicated_value_when_set() {
7957        let cfg = AppConfig {
7958            ollama_url: Some("http://ollama:11434".to_string()),
7959            embed_url: Some("http://embed:8080".to_string()),
7960            ..AppConfig::default()
7961        };
7962        // Dedicated embed_url wins.
7963        assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
7964    }
7965
7966    #[test]
7967    fn effective_embed_url_uses_default_when_neither_set() {
7968        let cfg = AppConfig::default();
7969        assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
7970    }
7971
7972    #[test]
7973    fn effective_archive_on_gc_default_is_true() {
7974        let cfg = AppConfig::default();
7975        assert!(cfg.effective_archive_on_gc());
7976    }
7977
7978    #[test]
7979    fn effective_archive_on_gc_respects_explicit_false() {
7980        let cfg = AppConfig {
7981            archive_on_gc: Some(false),
7982            ..AppConfig::default()
7983        };
7984        assert!(!cfg.effective_archive_on_gc());
7985    }
7986
7987    #[test]
7988    fn effective_autonomous_hooks_default_is_false() {
7989        // M9 — process-wide serialization via env_var_lock.
7990        let _g = env_var_lock();
7991        // SAFETY: env mutation serialised by `_g`.
7992        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
7993        let cfg = AppConfig::default();
7994        assert!(!cfg.effective_autonomous_hooks());
7995    }
7996
7997    #[test]
7998    fn effective_autonomous_hooks_config_value_used_when_env_unset() {
7999        // M9 — process-wide serialization via env_var_lock.
8000        let _g = env_var_lock();
8001        // SAFETY: env mutation serialised by `_g`.
8002        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
8003        let cfg = AppConfig {
8004            autonomous_hooks: Some(true),
8005            ..AppConfig::default()
8006        };
8007        assert!(cfg.effective_autonomous_hooks());
8008    }
8009
8010    #[test]
8011    fn effective_anonymize_default_falls_back_to_config() {
8012        // M9 — process-wide serialization via env_var_lock.
8013        let _g = env_var_lock();
8014        // SAFETY: env mutation serialised by `_g`.
8015        unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
8016        let cfg = AppConfig::default();
8017        assert!(!cfg.effective_anonymize_default());
8018    }
8019
8020    #[test]
8021    fn write_default_if_missing_creates_file_then_noops() {
8022        // M9 — process-wide serialization via env_var_lock.
8023        let _g = env_var_lock();
8024        // Use a temp dir as $HOME so we don't clobber a real config.
8025        let tmp = tempfile::tempdir().unwrap();
8026        // SAFETY: env mutation serialised by `_g`.
8027        unsafe { std::env::set_var("HOME", tmp.path()) };
8028        // First call writes the file.
8029        AppConfig::write_default_if_missing();
8030        let expected = AppConfig::config_path().unwrap();
8031        assert!(expected.exists(), "config not written at {expected:?}");
8032        let original = std::fs::read_to_string(&expected).unwrap();
8033        assert!(original.contains("ai-memory configuration"));
8034        // Second call must NOT overwrite (idempotent).
8035        std::fs::write(&expected, "# user-edited\n").unwrap();
8036        AppConfig::write_default_if_missing();
8037        let after = std::fs::read_to_string(&expected).unwrap();
8038        assert_eq!(after, "# user-edited\n");
8039    }
8040
8041    #[test]
8042    fn config_path_returns_some_when_home_set() {
8043        // M9 — process-wide serialization via env_var_lock.
8044        let _g = env_var_lock();
8045        // SAFETY: env mutation serialised by `_g`.
8046        unsafe { std::env::set_var("HOME", "/some/home") };
8047        let path = AppConfig::config_path().unwrap();
8048        assert!(path.starts_with("/some/home"));
8049    }
8050
8051    #[test]
8052    fn load_from_returns_default_for_missing_file() {
8053        // Non-existent path → default config.
8054        let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
8055        assert!(cfg.tier.is_none());
8056        assert!(cfg.db.is_none());
8057    }
8058
8059    #[test]
8060    fn load_from_returns_default_for_unparseable_toml() {
8061        // Garbage TOML → load_from prints a warning and returns default.
8062        let tmp = tempfile::NamedTempFile::new().unwrap();
8063        std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
8064        let cfg = AppConfig::load_from(tmp.path());
8065        assert!(cfg.tier.is_none());
8066    }
8067
8068    #[test]
8069    fn load_from_parses_valid_toml() {
8070        let tmp = tempfile::NamedTempFile::new().unwrap();
8071        std::fs::write(
8072            tmp.path(),
8073            r#"
8074                tier = "smart"
8075                db = "/disk.db"
8076            "#,
8077        )
8078        .unwrap();
8079        let cfg = AppConfig::load_from(tmp.path());
8080        assert_eq!(cfg.tier.as_deref(), Some("smart"));
8081        assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
8082    }
8083
8084    // -----------------------------------------------------------------
8085    // v0.7 I5 — auto_extract opt-in resolver
8086    // -----------------------------------------------------------------
8087
8088    #[test]
8089    fn auto_extract_default_off_when_no_namespaces_block() {
8090        let cfg = TranscriptsConfig::default();
8091        assert!(!cfg.auto_extract_for("agent/claude"));
8092        assert!(!cfg.auto_extract_for("anything"));
8093    }
8094
8095    #[test]
8096    fn auto_extract_exact_namespace_match_wins() {
8097        let mut nss = std::collections::HashMap::new();
8098        nss.insert(
8099            "agent/claude".into(),
8100            TranscriptNamespaceConfig {
8101                auto_extract: Some(true),
8102                ..Default::default()
8103            },
8104        );
8105        // Wildcard says "off" — exact match must still flip it on.
8106        nss.insert(
8107            "*".into(),
8108            TranscriptNamespaceConfig {
8109                auto_extract: Some(false),
8110                ..Default::default()
8111            },
8112        );
8113        let cfg = TranscriptsConfig {
8114            namespaces: Some(nss),
8115            ..Default::default()
8116        };
8117        assert!(cfg.auto_extract_for("agent/claude"));
8118        assert!(!cfg.auto_extract_for("agent/gpt"));
8119    }
8120
8121    #[test]
8122    fn auto_extract_prefix_match_then_wildcard_fallback() {
8123        let mut nss = std::collections::HashMap::new();
8124        nss.insert(
8125            "team/security/*".into(),
8126            TranscriptNamespaceConfig {
8127                auto_extract: Some(true),
8128                ..Default::default()
8129            },
8130        );
8131        nss.insert(
8132            "*".into(),
8133            TranscriptNamespaceConfig {
8134                auto_extract: Some(false),
8135                ..Default::default()
8136            },
8137        );
8138        let cfg = TranscriptsConfig {
8139            namespaces: Some(nss),
8140            ..Default::default()
8141        };
8142        assert!(cfg.auto_extract_for("team/security/audit"));
8143        assert!(!cfg.auto_extract_for("team/eng/main"));
8144    }
8145
8146    #[test]
8147    fn auto_extract_unset_field_inherits_default_off() {
8148        // A namespace block that sets only TTL — auto_extract is None
8149        // and so falls through to the next layer (wildcard, then off).
8150        let mut nss = std::collections::HashMap::new();
8151        nss.insert(
8152            "agent/claude".into(),
8153            TranscriptNamespaceConfig {
8154                default_ttl_secs: Some(crate::SECS_PER_HOUR),
8155                auto_extract: None,
8156                ..Default::default()
8157            },
8158        );
8159        let cfg = TranscriptsConfig {
8160            namespaces: Some(nss),
8161            ..Default::default()
8162        };
8163        assert!(!cfg.auto_extract_for("agent/claude"));
8164    }
8165
8166    // -----------------------------------------------------------------
8167    // L1 fix (v0.7.0): unknown top-level keys WARN diagnostic
8168    // -----------------------------------------------------------------
8169    //
8170    // The earlier Plan C bug planted `[memory]`, `[autonomous]`,
8171    // `[governance]`, `[federation]` tables in the operator's
8172    // config.toml — none of them are real `AppConfig` fields, so serde
8173    // silently dropped them and the operator's intent never reached the
8174    // daemon. The fix warns on every unknown top-level key while still
8175    // loading the config gracefully.
8176
8177    /// Top-level key not in `AppConfig` is reported via `tracing::warn!`
8178    /// AND the config still loads with recognised fields intact.
8179    #[test]
8180    fn load_from_warns_on_unknown_top_level_key_but_still_loads() {
8181        // Construct a config that mixes a real key (`tier`) with the
8182        // unknown `[memory]` table from the Plan C bug. The recognised
8183        // `tier = "autonomous"` at the top level must survive (i.e. the
8184        // unknown `[memory] tier = "ignored"` does NOT shadow it —
8185        // top-level wins because `[memory]` is a different namespace
8186        // entirely from `AppConfig.tier`).
8187        let toml_src = "tier = \"autonomous\"\n\n[memory]\ntier = \"ignored\"\n";
8188
8189        let tmp = tempfile::NamedTempFile::new().expect("create temp file");
8190        std::fs::write(tmp.path(), toml_src).expect("write temp config");
8191
8192        // We do NOT install a tracing subscriber here — `tracing-test`
8193        // is not a dev-dep, and the spec explicitly allows skipping the
8194        // "warn-was-emitted" assertion when capturing is awkward. The
8195        // important contract is:
8196        //   (a) load_from returns a populated AppConfig (no panic),
8197        //   (b) the recognised top-level `tier` survives,
8198        //   (c) the unknown `[memory]` table did NOT block the load.
8199        // The warn itself is exercised at runtime — verify it fires by
8200        // running `RUST_LOG=warn AI_MEMORY_NO_CONFIG=0 ai-memory ...`
8201        // against a config with a stray section.
8202        let cfg = AppConfig::load_from(tmp.path());
8203
8204        assert_eq!(
8205            cfg.tier.as_deref(),
8206            Some("autonomous"),
8207            "top-level `tier` must survive even when an unknown `[memory]` table is present",
8208        );
8209    }
8210
8211    /// Every field in `AppConfig` is enumerated in the expected-key
8212    /// set, so renaming a struct field will not silently start
8213    /// emitting bogus warnings for the new name.
8214    ///
8215    /// Regression guard: if you add a new top-level field to
8216    /// `AppConfig`, you MUST also add it to the `EXPECTED_KEYS` const
8217    /// inside `AppConfig::warn_unknown_top_level_keys`. This test
8218    /// enforces parity by serialising a fully-populated `AppConfig` to
8219    /// TOML and asserting that every emitted top-level key is in the
8220    /// expected set.
8221    #[test]
8222    fn warn_unknown_top_level_keys_covers_every_appconfig_field() {
8223        // Build an AppConfig with every Option populated so serde emits
8224        // every field. We only need the keys, not the values, so
8225        // default placeholder sub-structs are fine.
8226        let cfg = AppConfig {
8227            tier: Some("keyword".into()),
8228            db: Some(String::new()),
8229            ollama_url: Some(String::new()),
8230            embed_url: Some(String::new()),
8231            embedding_model: Some(String::new()),
8232            llm_model: Some(String::new()),
8233            auto_tag_model: Some(String::new()),
8234            cross_encoder: Some(false),
8235            default_namespace: Some(String::new()),
8236            max_memory_mb: Some(0),
8237            ttl: Some(TtlConfig::default()),
8238            archive_on_gc: Some(false),
8239            api_key: Some(String::new()),
8240            archive_max_days: Some(0),
8241            identity: Some(IdentityConfig::default()),
8242            scoring: Some(RecallScoringConfig::default()),
8243            autonomous_hooks: Some(false),
8244            logging: Some(LoggingConfig::default()),
8245            audit: Some(AuditConfig::default()),
8246            boot: Some(BootConfig::default()),
8247            mcp: Some(McpConfig::default()),
8248            permissions: Some(PermissionsConfig::default()),
8249            transcripts: Some(TranscriptsConfig::default()),
8250            hooks: Some(HooksConfig::default()),
8251            subscriptions: Some(SubscriptionsConfig::default()),
8252            postgres_statement_timeout_secs: Some(30),
8253            postgres_pool_max_connections: Some(16),
8254            postgres_pool_min_connections: Some(2),
8255            postgres_acquire_timeout_secs: Some(30),
8256            request_timeout_secs: Some(60),
8257            llm_call_timeout_secs: Some(30),
8258            verify: Some(VerifyConfig::default()),
8259            mcp_federation_forward_url: Some(String::new()),
8260            agents: Some(AgentsConfig::default()),
8261            governance: Some(GovernanceConfig::default()),
8262            confidence: Some(ConfidenceConfig::default()),
8263            admin: Some(AdminConfig::default()),
8264            // v0.7.x (#1146) — enterprise configuration sections.
8265            schema_version: Some(2),
8266            llm: Some(LlmSection::default()),
8267            embeddings: Some(EmbeddingsSection::default()),
8268            reranker: Some(RerankerSection::default()),
8269            storage: Some(StorageSection::default()),
8270            limits: Some(LimitsSection::default()),
8271        };
8272
8273        let serialised = toml::to_string(&cfg).expect("serialise AppConfig to TOML");
8274        let value: toml::Value =
8275            toml::from_str(&serialised).expect("re-parse serialised AppConfig");
8276        let table = value.as_table().expect("serialised AppConfig is a table");
8277
8278        // Mirror the const in `warn_unknown_top_level_keys`. Keep in
8279        // sync — if this assertion fires, you forgot to update the
8280        // expected-keys list when adding a new AppConfig field.
8281        const EXPECTED_KEYS: &[&str] = &[
8282            "tier",
8283            "db",
8284            "ollama_url",
8285            "embed_url",
8286            "embedding_model",
8287            "llm_model",
8288            "auto_tag_model",
8289            "cross_encoder",
8290            "default_namespace",
8291            "max_memory_mb",
8292            "ttl",
8293            "archive_on_gc",
8294            "api_key",
8295            "archive_max_days",
8296            "identity",
8297            "scoring",
8298            "autonomous_hooks",
8299            "logging",
8300            "audit",
8301            "boot",
8302            "mcp",
8303            "permissions",
8304            "transcripts",
8305            "hooks",
8306            "subscriptions",
8307            "postgres_statement_timeout_secs",
8308            "postgres_pool_max_connections",
8309            "postgres_pool_min_connections",
8310            "postgres_acquire_timeout_secs",
8311            "request_timeout_secs",
8312            "llm_call_timeout_secs",
8313            "verify",
8314            "mcp_federation_forward_url",
8315            "agents",
8316            "governance",
8317            "confidence",
8318            "admin",
8319            // v0.7.x (#1146) — enterprise configuration sections.
8320            "schema_version",
8321            "llm",
8322            "embeddings",
8323            "reranker",
8324            "storage",
8325            "limits",
8326        ];
8327
8328        for key in table.keys() {
8329            assert!(
8330                EXPECTED_KEYS.contains(&key.as_str()),
8331                "AppConfig field `{key}` is not in EXPECTED_KEYS — \
8332                 update `warn_unknown_top_level_keys` to keep parity",
8333            );
8334        }
8335    }
8336
8337    /// v0.7.0 L15 — assert that:
8338    ///  1. `AppConfig::default()` leaves `auto_tag_model` as `None` so a
8339    ///     daemon with no operator override sees the absent state (which
8340    ///     `maybe_auto_tag` interprets as "use the client's configured
8341    ///     `llm_model`"); and
8342    ///  2. the documented default config.toml template spot-checks
8343    ///     `gemma3:4b` as the recommended value — closes the L14
8344    ///     NHI-D-autotag-empty finding where Gemma 4 thinking-mode
8345    ///     latency hit the 30s autonomy timeout.
8346    #[test]
8347    fn auto_tag_model_default_falls_back_to_none_and_template_documents_default_gemma3_4b() {
8348        // (1) compile-time default leaves auto_tag_model = None.
8349        let cfg = AppConfig::default();
8350        assert!(
8351            cfg.auto_tag_model.is_none(),
8352            "fresh AppConfig must leave auto_tag_model = None so callers \
8353             fall back to llm_model"
8354        );
8355
8356        // (2) the default config.toml template the daemon writes to disk
8357        // must document the recommended gemma3:4b value and mention
8358        // auto_tag_model — operators rely on the inline template as the
8359        // authoritative knob reference.
8360        //
8361        // We can't reach the private `default_toml` constant directly,
8362        // so write it to a tempdir via `write_default_if_missing` and
8363        // read it back. Mirrors the pattern used by
8364        // `default_config_includes_*` tests above.
8365        //
8366        // M9 — HOME mutation is process-global; other tests in this
8367        // module also flip HOME. Serialise via env_var_lock so parallel
8368        // `cargo test --jobs N` runs cannot interleave reads of HOME
8369        // mid-mutation.
8370        let _g = env_var_lock();
8371        let tmp = tempfile::tempdir().expect("tempdir");
8372        // SAFETY: env mutation serialised by `_g`.
8373        unsafe { std::env::set_var("HOME", tmp.path()) };
8374        AppConfig::write_default_if_missing();
8375        let written = AppConfig::config_path().expect("config_path resolves");
8376        let contents = std::fs::read_to_string(&written).expect("default toml written");
8377        assert!(
8378            contents.contains("auto_tag_model"),
8379            "default config.toml must document the auto_tag_model knob; \
8380             got:\n{contents}"
8381        );
8382        assert!(
8383            contents.contains("gemma3:4b"),
8384            "default config.toml must mention gemma3:4b as the L15 \
8385             recommended default; got:\n{contents}"
8386        );
8387    }
8388
8389    // ---- C-5 (#699): close lib-tier gaps in config.rs (currently 90.76%).
8390    // Targets serde default functions, env-var override branches, and
8391    // display impls that no other test exercises. ----
8392
8393    #[test]
8394    fn tier_llm_model_is_agnostic_gate() {
8395        // The Gemma-only `LlmModel` enum was removed (#1490): no model name
8396        // survives as a config-surface identifier. The LLM-capable tiers
8397        // carry the provider-agnostic compiled default; keyword/semantic
8398        // carry `None` (LLM disabled). Pin the gate + the single-source-of-
8399        // truth default rather than any hardcoded vendor string.
8400        assert!(FeatureTier::Keyword.config().llm_model.is_none());
8401        assert!(FeatureTier::Semantic.config().llm_model.is_none());
8402        assert_eq!(
8403            FeatureTier::Smart.config().llm_model.as_deref(),
8404            Some(default_tier_llm_model())
8405        );
8406        assert_eq!(
8407            FeatureTier::Autonomous.config().llm_model.as_deref(),
8408            Some(default_tier_llm_model())
8409        );
8410        // The default routes through the agnostic resolver table, never a
8411        // model-named identifier.
8412        assert_eq!(
8413            default_tier_llm_model(),
8414            backend_default_model(crate::llm::BACKEND_OLLAMA)
8415        );
8416    }
8417
8418    #[test]
8419    fn feature_tier_display_matches_as_str() {
8420        // Lines 183-185: `FeatureTier::Display::fmt` writes `as_str`.
8421        assert_eq!(format!("{}", FeatureTier::Keyword), "keyword");
8422        assert_eq!(format!("{}", FeatureTier::Semantic), "semantic");
8423        assert_eq!(format!("{}", FeatureTier::Smart), "smart");
8424        assert_eq!(format!("{}", FeatureTier::Autonomous), "autonomous");
8425    }
8426
8427    #[test]
8428    fn default_recall_mode_is_disabled() {
8429        // Lines 630-632: serde default helper.
8430        assert_eq!(default_recall_mode(), RecallMode::Disabled);
8431    }
8432
8433    #[test]
8434    fn default_reranker_mode_is_off() {
8435        // Lines 634-636: serde default helper.
8436        assert_eq!(default_reranker_mode(), RerankerMode::Off);
8437    }
8438
8439    #[test]
8440    fn default_hook_events_count_matches_constant() {
8441        // Lines 731-733: serde default helper.
8442        assert_eq!(default_hook_events_count(), HOOK_EVENTS_COUNT);
8443    }
8444
8445    #[test]
8446    fn default_reflection_boost_returns_default_report() {
8447        // Lines 621-623: serde default helper. Calls the `Default::default`
8448        // impl on `ReflectionBoostReport`.
8449        let r = default_reflection_boost();
8450        let d = ReflectionBoostReport::default();
8451        // Lazy compare via Debug — the struct has no PartialEq.
8452        assert_eq!(format!("{r:?}"), format!("{d:?}"));
8453    }
8454
8455    #[test]
8456    fn permissions_mode_default_is_advisory() {
8457        // Lines 2403-2405: `impl Default for PermissionsMode`.
8458        let m: PermissionsMode = Default::default();
8459        assert_eq!(m, PermissionsMode::Advisory);
8460    }
8461
8462    #[test]
8463    fn active_permissions_mode_uses_named_fallback_when_unset_then_honors_setter() {
8464        // v0.7.0 H2 de-silencing: when boot has NOT installed a mode,
8465        // the gate reader returns the explicit
8466        // UNINITIALIZED_PERMISSIONS_MODE_FALLBACK constant (and emits a
8467        // one-shot WARN). Once a mode is installed, the reader honors it.
8468        let _serialise = lock_permissions_mode_for_test();
8469        clear_permissions_mode_override_for_test();
8470        assert_eq!(
8471            active_permissions_mode(),
8472            UNINITIALIZED_PERMISSIONS_MODE_FALLBACK,
8473            "unset gate must return the named pre-init fallback"
8474        );
8475        set_active_permissions_mode(PermissionsMode::Enforce);
8476        assert_eq!(
8477            active_permissions_mode(),
8478            PermissionsMode::Enforce,
8479            "installed mode must win over the fallback"
8480        );
8481        // Restore the unset state for subsequent tests.
8482        clear_permissions_mode_override_for_test();
8483    }
8484
8485    #[test]
8486    fn set_allow_loopback_webhooks_round_trips() {
8487        // Lines 2357-2359: pub setter — just observe it does not panic
8488        // and that effective_allow_loopback_webhooks can read the value.
8489        // (The atomic is process-global; restore the prior value at end.)
8490        let prior = ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst);
8491        set_allow_loopback_webhooks(true);
8492        assert!(ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
8493        set_allow_loopback_webhooks(false);
8494        assert!(!ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
8495        // Restore.
8496        ALLOW_LOOPBACK_WEBHOOKS.store(prior, std::sync::atomic::Ordering::SeqCst);
8497    }
8498
8499    #[test]
8500    fn reset_permissions_decision_counts_zeros_all_atomics() {
8501        // Lines 2619-2623: test-only reset helper. Increment then reset.
8502        // Post-#1174 PR7: counters live behind the `DECISION_COUNTERS`
8503        // struct; we exercise them via the public surface to keep the
8504        // test resilient to internal reshape.
8505        let _serialise = lock_permissions_mode_for_test();
8506        reset_permissions_decision_counts_for_test();
8507        record_permissions_decision(PermissionsMode::Enforce);
8508        record_permissions_decision(PermissionsMode::Enforce);
8509        record_permissions_decision(PermissionsMode::Enforce);
8510        record_permissions_decision(PermissionsMode::Enforce);
8511        record_permissions_decision(PermissionsMode::Enforce);
8512        record_permissions_decision(PermissionsMode::Advisory);
8513        record_permissions_decision(PermissionsMode::Advisory);
8514        record_permissions_decision(PermissionsMode::Advisory);
8515        record_permissions_decision(PermissionsMode::Off);
8516        let pre = permissions_decision_counts();
8517        assert_eq!(pre.enforce, 5);
8518        assert_eq!(pre.advisory, 3);
8519        assert_eq!(pre.off, 1);
8520        reset_permissions_decision_counts_for_test();
8521        let post = permissions_decision_counts();
8522        assert_eq!(post.enforce, 0);
8523        assert_eq!(post.advisory, 0);
8524        assert_eq!(post.off, 0);
8525    }
8526
8527    #[test]
8528    fn effective_allow_loopback_webhooks_env_var_true_returns_true() {
8529        // Lines 2281-2297: env-var override branch (truthy).
8530        let _g = env_var_lock();
8531        let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8532        unsafe {
8533            std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "yes");
8534        }
8535        let cfg = AppConfig::default();
8536        assert!(cfg.effective_allow_loopback_webhooks());
8537        unsafe {
8538            match prior {
8539                Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8540                None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8541            }
8542        }
8543    }
8544
8545    #[test]
8546    fn effective_allow_loopback_webhooks_env_var_false_returns_false() {
8547        // Lines 2281-2297: env-var override (falsy).
8548        let _g = env_var_lock();
8549        let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8550        unsafe {
8551            std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "no");
8552        }
8553        let cfg = AppConfig::default();
8554        assert!(!cfg.effective_allow_loopback_webhooks());
8555        unsafe {
8556            match prior {
8557                Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8558                None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8559            }
8560        }
8561    }
8562
8563    #[test]
8564    fn effective_allow_loopback_webhooks_env_var_invalid_falls_back_to_config() {
8565        // Lines 2286-2292: invalid env value falls back to config.toml.
8566        let _g = env_var_lock();
8567        let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8568        unsafe {
8569            std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "kinda");
8570        }
8571        let cfg = AppConfig::default();
8572        // With no [subscriptions] table the default is false.
8573        assert!(!cfg.effective_allow_loopback_webhooks());
8574        unsafe {
8575            match prior {
8576                Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8577                None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8578            }
8579        }
8580    }
8581
8582    #[test]
8583    fn effective_permissions_mode_env_var_enforce_wins() {
8584        // Lines 3144-3169: env override path → Enforce.
8585        let _g = env_var_lock();
8586        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8587        unsafe {
8588            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "enforce");
8589        }
8590        let cfg = AppConfig::default();
8591        assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Enforce);
8592        unsafe {
8593            match prior {
8594                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8595                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8596            }
8597        }
8598    }
8599
8600    #[test]
8601    fn effective_permissions_mode_env_var_advisory_wins() {
8602        // Lines 3148: env override path → Advisory.
8603        let _g = env_var_lock();
8604        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8605        unsafe {
8606            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "ADVISORY");
8607        }
8608        let cfg = AppConfig::default();
8609        assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Advisory);
8610        unsafe {
8611            match prior {
8612                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8613                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8614            }
8615        }
8616    }
8617
8618    #[test]
8619    fn effective_permissions_mode_env_var_off_wins() {
8620        // Lines 3149: env override path → Off.
8621        let _g = env_var_lock();
8622        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8623        unsafe {
8624            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "off");
8625        }
8626        let cfg = AppConfig::default();
8627        assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Off);
8628        unsafe {
8629            match prior {
8630                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8631                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8632            }
8633        }
8634    }
8635
8636    #[test]
8637    fn effective_permissions_mode_env_var_invalid_falls_back_to_config() {
8638        // Lines 3150-3156: invalid env → falls through to resolve_v07_default_mode.
8639        let _g = env_var_lock();
8640        let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8641        unsafe {
8642            std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "weird");
8643        }
8644        let cfg = AppConfig::default();
8645        // The resolver returns a value (we don't pin which — just that it returns).
8646        let _ = cfg.effective_permissions_mode();
8647        unsafe {
8648            match prior {
8649                Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8650                None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8651            }
8652        }
8653    }
8654
8655    #[test]
8656    fn effective_permission_rules_returns_empty_when_unset() {
8657        // Lines 3178-3183: empty-rules path.
8658        let cfg = AppConfig::default();
8659        let rules = cfg.effective_permission_rules();
8660        assert!(rules.is_empty());
8661    }
8662
8663    #[test]
8664    fn app_config_load_with_no_config_env_returns_default() {
8665        // Lines 3015-3022: `AppConfig::load` with AI_MEMORY_NO_CONFIG=1.
8666        let _g = env_var_lock();
8667        let prior = std::env::var("AI_MEMORY_NO_CONFIG").ok();
8668        unsafe {
8669            std::env::set_var("AI_MEMORY_NO_CONFIG", "1");
8670        }
8671        let cfg = AppConfig::load();
8672        // Default config has no tier/db set.
8673        assert!(
8674            cfg.tier.is_none()
8675                || cfg.tier == Some("semantic".to_string())
8676                || cfg.tier == Some("keyword".to_string())
8677        );
8678        unsafe {
8679            match prior {
8680                Some(v) => std::env::set_var("AI_MEMORY_NO_CONFIG", v),
8681                None => std::env::remove_var("AI_MEMORY_NO_CONFIG"),
8682            }
8683        }
8684    }
8685
8686    // ---- C-5 (#699) round 2: round out the easy Default impls + serde
8687    // default helpers that bumped lines 805/852/955/1019/1057/1125/1634+ ----
8688
8689    #[test]
8690    fn capability_compaction_default_is_planned() {
8691        // Lines 804-808.
8692        let d: CapabilityCompaction = Default::default();
8693        let planned = CapabilityCompaction::planned();
8694        // Compare via Debug since the struct has no PartialEq.
8695        assert_eq!(format!("{d:?}"), format!("{planned:?}"));
8696    }
8697
8698    #[test]
8699    fn capability_transcripts_default_is_planned() {
8700        // Lines 851-855.
8701        let d: CapabilityTranscripts = Default::default();
8702        let planned = CapabilityTranscripts::planned();
8703        assert_eq!(format!("{d:?}"), format!("{planned:?}"));
8704    }
8705
8706    #[test]
8707    fn default_capability_reflection_helper_returns_current() {
8708        // Lines 955-957.
8709        let helper = default_capability_reflection();
8710        let current = CapabilityReflection::current();
8711        assert_eq!(format!("{helper:?}"), format!("{current:?}"));
8712    }
8713
8714    #[test]
8715    fn default_capability_skills_helper_returns_current() {
8716        // Lines 1019-1021.
8717        let helper = default_capability_skills();
8718        let current = CapabilitySkills::current();
8719        assert_eq!(helper, current);
8720    }
8721
8722    #[test]
8723    fn default_capability_forensic_helper_returns_current() {
8724        // Lines 1057-1059.
8725        let helper = default_capability_forensic();
8726        let current = CapabilityForensic::current();
8727        assert_eq!(helper, current);
8728    }
8729
8730    #[test]
8731    fn default_capability_governance_helper_returns_current() {
8732        // Lines 1125-1127.
8733        let helper = default_capability_governance();
8734        let current = CapabilityGovernance::current();
8735        assert_eq!(helper, current);
8736    }
8737
8738    #[test]
8739    fn default_capability_atomisation_helper_returns_current() {
8740        // v0.7.0 WT-1-G — mirrors the governance/forensic/skills/reflection
8741        // helper round-trip: the `#[serde(default = …)]` resolver must
8742        // collapse to the same compile-anchored snapshot
8743        // [`CapabilityAtomisation::current`] returns.
8744        let helper = default_capability_atomisation();
8745        let current = CapabilityAtomisation::current();
8746        assert_eq!(helper, current);
8747    }
8748
8749    #[test]
8750    fn resolved_transcript_lifecycle_default_uses_compiled_defaults() {
8751        // Lines 1633-1639.
8752        let r: ResolvedTranscriptLifecycle = Default::default();
8753        assert_eq!(r.default_ttl_secs, DEFAULT_TRANSCRIPT_TTL_SECS);
8754        assert_eq!(r.archive_grace_secs, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS);
8755    }
8756
8757    #[test]
8758    fn default_memory_kinds_lists_observation_and_reflection() {
8759        // Lines 626-628: serde default helper covers L1-1 typed kinds.
8760        let kinds = default_memory_kinds();
8761        assert_eq!(
8762            kinds,
8763            vec!["observation".to_string(), "reflection".to_string()]
8764        );
8765    }
8766
8767    /// v0.7.0 Gap 4 (#887) — pin the capabilities-surface thresholds
8768    /// to the `ConfidenceTier` model constants so a future
8769    /// re-tuning bumps BOTH in lockstep (or the build breaks).
8770    #[test]
8771    fn confidence_tier_thresholds_match_model_constants() {
8772        let defaults = ConfidenceTierThresholds::default();
8773        assert!(
8774            (defaults.confirmed - crate::models::ConfidenceTier::CONFIRMED_MIN).abs()
8775                < f64::EPSILON,
8776            "ConfidenceTierThresholds.confirmed must match ConfidenceTier::CONFIRMED_MIN"
8777        );
8778        assert!(
8779            (defaults.likely - crate::models::ConfidenceTier::LIKELY_MIN).abs() < f64::EPSILON,
8780            "ConfidenceTierThresholds.likely must match ConfidenceTier::LIKELY_MIN"
8781        );
8782        // Ambiguous is the implicit floor — pin it to zero so the
8783        // wire shape is fully self-describing.
8784        assert!(
8785            (defaults.ambiguous - 0.0).abs() < f64::EPSILON,
8786            "ambiguous floor is fixed at 0.0"
8787        );
8788    }
8789
8790    /// v0.7.0 Gap 4 (#887) — every `TierConfig::capabilities()` call
8791    /// must surface the calibration block so MCP capability readers
8792    /// can rely on the field being present.
8793    #[test]
8794    fn capability_confidence_calibration_carries_tier_thresholds() {
8795        // `CapabilityConfidenceCalibration::current()` (the
8796        // capabilities v3 builder) surfaces the Gap 4 thresholds so
8797        // MCP capability readers can filter without re-deriving the
8798        // breakpoints.
8799        let surface = CapabilityConfidenceCalibration::current();
8800        assert!((surface.tier_thresholds.confirmed - 0.95).abs() < f64::EPSILON);
8801        assert!((surface.tier_thresholds.likely - 0.7).abs() < f64::EPSILON);
8802        assert!((surface.tier_thresholds.ambiguous - 0.0).abs() < f64::EPSILON);
8803    }
8804
8805    // ---------------------------------------------------------------------
8806    // v0.7.x enterprise-config tests (#1146)
8807    //
8808    // Pin: precedence ladder per resolver (CLI > env > config > legacy >
8809    // compiled), inline-key rejection at parse time, api_key_env /
8810    // api_key_file resolution, Once-gated legacy-drift WARN.
8811    // ---------------------------------------------------------------------
8812
8813    fn empty_app_config() -> AppConfig {
8814        AppConfig {
8815            schema_version: Some(2),
8816            ..AppConfig::default()
8817        }
8818    }
8819
8820    fn scrub_llm_env() {
8821        for k in [
8822            "AI_MEMORY_LLM_BACKEND",
8823            "AI_MEMORY_LLM_MODEL",
8824            "AI_MEMORY_LLM_BASE_URL",
8825            "AI_MEMORY_LLM_API_KEY",
8826            "XAI_API_KEY",
8827            "OPENAI_API_KEY",
8828            "ANTHROPIC_API_KEY",
8829            "GEMINI_API_KEY",
8830            "GOOGLE_API_KEY",
8831            "DEEPSEEK_API_KEY",
8832            "AI_MEMORY_EMBED_BACKFILL_BATCH",
8833            "AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS",
8834        ] {
8835            unsafe {
8836                std::env::remove_var(k);
8837            }
8838        }
8839    }
8840
8841    /// #1598 — scrub the embeddings-resolver env surface (and the
8842    /// alias-fallback vendor key vars the precedence tests exercise)
8843    /// so `resolve_embeddings` tests are hermetic. Callers hold
8844    /// `env_var_lock()`.
8845    fn scrub_embed_env() {
8846        for k in [
8847            ENV_EMBED_BACKEND,
8848            ENV_EMBED_BASE_URL,
8849            ENV_EMBED_MODEL,
8850            ENV_EMBED_API_KEY,
8851            ENV_EMBED_BACKFILL_BATCH,
8852            "OPENROUTER_API_KEY",
8853            "GEMINI_API_KEY",
8854            "GOOGLE_API_KEY",
8855        ] {
8856            unsafe {
8857                std::env::remove_var(k);
8858            }
8859        }
8860    }
8861
8862    fn scrub_limits_env() {
8863        for k in [
8864            ENV_MAX_MEMORIES_PER_DAY,
8865            ENV_MAX_STORAGE_BYTES,
8866            ENV_MAX_LINKS_PER_DAY,
8867            ENV_MAX_PAGE_SIZE,
8868        ] {
8869            unsafe {
8870                std::env::remove_var(k);
8871            }
8872        }
8873    }
8874
8875    #[test]
8876    fn resolve_limits_compiled_default_when_nothing_configured() {
8877        let _g = env_var_lock();
8878        scrub_limits_env();
8879        let cfg = empty_app_config();
8880        let r = cfg.resolve_limits();
8881        assert_eq!(
8882            r.max_memories_per_day,
8883            crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
8884        );
8885        assert_eq!(
8886            r.max_storage_bytes,
8887            crate::quotas::DEFAULT_MAX_STORAGE_BYTES
8888        );
8889        assert_eq!(
8890            r.max_links_per_day,
8891            crate::quotas::DEFAULT_MAX_LINKS_PER_DAY
8892        );
8893        assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
8894        assert_eq!(r.source, ConfigSource::CompiledDefault);
8895    }
8896
8897    #[test]
8898    fn resolve_limits_config_section_when_no_env() {
8899        let _g = env_var_lock();
8900        scrub_limits_env();
8901        let mut cfg = empty_app_config();
8902        cfg.limits = Some(LimitsSection {
8903            max_memories_per_day: Some(5_000_000),
8904            max_storage_bytes: Some(9_000_000_000),
8905            max_links_per_day: Some(4_000_000),
8906            max_page_size: Some(250_000),
8907        });
8908        let r = cfg.resolve_limits();
8909        assert_eq!(r.max_memories_per_day, 5_000_000);
8910        assert_eq!(r.max_storage_bytes, 9_000_000_000);
8911        assert_eq!(r.max_links_per_day, 4_000_000);
8912        assert_eq!(r.max_page_size, 250_000);
8913        assert_eq!(r.source, ConfigSource::Config);
8914    }
8915
8916    #[test]
8917    fn resolve_limits_env_overrides_config_section() {
8918        let _g = env_var_lock();
8919        scrub_limits_env();
8920        unsafe {
8921            std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "7000000");
8922            std::env::set_var(ENV_MAX_PAGE_SIZE, "123456");
8923        }
8924        let mut cfg = empty_app_config();
8925        cfg.limits = Some(LimitsSection {
8926            max_memories_per_day: Some(5_000_000),
8927            max_storage_bytes: Some(9_000_000_000),
8928            max_links_per_day: Some(4_000_000),
8929            max_page_size: Some(250_000),
8930        });
8931        let r = cfg.resolve_limits();
8932        // env wins for the two it sets …
8933        assert_eq!(r.max_memories_per_day, 7_000_000, "env beats config");
8934        assert_eq!(r.max_page_size, 123_456, "env beats config");
8935        // … and config still supplies the fields env left unset.
8936        assert_eq!(r.max_storage_bytes, 9_000_000_000);
8937        assert_eq!(r.max_links_per_day, 4_000_000);
8938        assert_eq!(r.source, ConfigSource::Env);
8939        scrub_limits_env();
8940    }
8941
8942    #[test]
8943    fn resolve_limits_zero_and_garbage_env_fall_through() {
8944        let _g = env_var_lock();
8945        scrub_limits_env();
8946        unsafe {
8947            std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "0"); // non-positive → ignored
8948            std::env::set_var(ENV_MAX_STORAGE_BYTES, "not-a-number"); // unparseable → ignored
8949            std::env::set_var(ENV_MAX_PAGE_SIZE, "-5"); // negative → unparseable as usize → ignored
8950        }
8951        let cfg = empty_app_config();
8952        let r = cfg.resolve_limits();
8953        // every stray env value falls through to the compiled default.
8954        assert_eq!(
8955            r.max_memories_per_day,
8956            crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
8957        );
8958        assert_eq!(
8959            r.max_storage_bytes,
8960            crate::quotas::DEFAULT_MAX_STORAGE_BYTES
8961        );
8962        assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
8963        assert_eq!(r.source, ConfigSource::CompiledDefault);
8964        scrub_limits_env();
8965    }
8966
8967    #[test]
8968    fn resolve_limits_zero_config_value_falls_through_to_default() {
8969        let _g = env_var_lock();
8970        scrub_limits_env();
8971        let mut cfg = empty_app_config();
8972        cfg.limits = Some(LimitsSection {
8973            max_page_size: Some(0), // non-positive → ignored
8974            ..LimitsSection::default()
8975        });
8976        let r = cfg.resolve_limits();
8977        assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
8978        assert_eq!(r.source, ConfigSource::CompiledDefault);
8979    }
8980
8981    #[test]
8982    fn resolve_limits_section_round_trips_through_toml() {
8983        let toml = r#"
8984schema_version = 2
8985
8986[limits]
8987max_memories_per_day = 10000000
8988max_storage_bytes = 50000000000
8989max_links_per_day = 8000000
8990max_page_size = 1000000
8991"#;
8992        let cfg: AppConfig = toml::from_str(toml).expect("parse [limits] toml");
8993        let l = cfg.limits.as_ref().expect("limits section present");
8994        assert_eq!(l.max_memories_per_day, Some(10_000_000));
8995        assert_eq!(l.max_storage_bytes, Some(50_000_000_000));
8996        assert_eq!(l.max_links_per_day, Some(8_000_000));
8997        assert_eq!(l.max_page_size, Some(1_000_000));
8998        // env-free resolve picks up the config values verbatim.
8999        let _g = env_var_lock();
9000        scrub_limits_env();
9001        let r = cfg.resolve_limits();
9002        assert_eq!(r.max_memories_per_day, 10_000_000);
9003        assert_eq!(r.max_page_size, 1_000_000);
9004        assert_eq!(r.source, ConfigSource::Config);
9005    }
9006
9007    #[cfg(feature = "sal")]
9008    fn scrub_pg_pool_env() {
9009        for k in [
9010            ENV_PG_POOL_MAX,
9011            ENV_PG_POOL_MIN,
9012            ENV_PG_ACQUIRE_TIMEOUT_SECS,
9013        ] {
9014            unsafe {
9015                std::env::remove_var(k);
9016            }
9017        }
9018    }
9019
9020    #[cfg(feature = "sal")]
9021    #[test]
9022    fn resolve_pg_pool_compiled_default_when_nothing_configured() {
9023        let _g = env_var_lock();
9024        scrub_pg_pool_env();
9025        let cfg = empty_app_config();
9026        let r = cfg.resolve_pg_pool();
9027        assert_eq!(r, crate::store::PoolConfig::default());
9028    }
9029
9030    #[cfg(feature = "sal")]
9031    #[test]
9032    fn resolve_pg_pool_config_overrides_default() {
9033        let _g = env_var_lock();
9034        scrub_pg_pool_env();
9035        let mut cfg = empty_app_config();
9036        cfg.postgres_pool_max_connections = Some(64);
9037        cfg.postgres_pool_min_connections = Some(8);
9038        cfg.postgres_acquire_timeout_secs = Some(15);
9039        let r = cfg.resolve_pg_pool();
9040        assert_eq!(r.max_connections, 64);
9041        assert_eq!(r.min_connections, 8);
9042        assert_eq!(r.acquire_timeout_secs, 15);
9043    }
9044
9045    #[cfg(feature = "sal")]
9046    #[test]
9047    fn resolve_pg_pool_env_overrides_config() {
9048        let _g = env_var_lock();
9049        scrub_pg_pool_env();
9050        unsafe {
9051            std::env::set_var(ENV_PG_POOL_MAX, "100");
9052            std::env::set_var(ENV_PG_ACQUIRE_TIMEOUT_SECS, "45");
9053        }
9054        let mut cfg = empty_app_config();
9055        cfg.postgres_pool_max_connections = Some(64);
9056        cfg.postgres_pool_min_connections = Some(8);
9057        cfg.postgres_acquire_timeout_secs = Some(15);
9058        let r = cfg.resolve_pg_pool();
9059        // env wins for the two it sets …
9060        assert_eq!(r.max_connections, 100, "env beats config");
9061        assert_eq!(r.acquire_timeout_secs, 45, "env beats config");
9062        // … and config still supplies the field env left unset.
9063        assert_eq!(r.min_connections, 8);
9064        scrub_pg_pool_env();
9065    }
9066
9067    #[cfg(feature = "sal")]
9068    #[test]
9069    fn resolve_pg_pool_zero_and_garbage_fall_through() {
9070        let _g = env_var_lock();
9071        scrub_pg_pool_env();
9072        unsafe {
9073            std::env::set_var(ENV_PG_POOL_MAX, "0"); // non-positive → ignored
9074            std::env::set_var(ENV_PG_POOL_MIN, "not-a-number"); // unparseable → ignored
9075        }
9076        let mut cfg = empty_app_config();
9077        // A zero config value must also fall through, never clamp the pool.
9078        cfg.postgres_acquire_timeout_secs = Some(0);
9079        let r = cfg.resolve_pg_pool();
9080        // every stray value falls through to the compiled default.
9081        assert_eq!(r, crate::store::PoolConfig::default());
9082        scrub_pg_pool_env();
9083    }
9084
9085    #[cfg(feature = "sal")]
9086    #[test]
9087    fn pg_pool_env_const_names_byte_match_documented() {
9088        // Doc-name-match guard: these byte values are documented in
9089        // CLAUDE.md's Environment Variables table + the enterprise
9090        // deployment guide §5.6. Pin the drift so it can never recur.
9091        assert_eq!(ENV_PG_POOL_MAX, "AI_MEMORY_PG_POOL_MAX");
9092        assert_eq!(ENV_PG_POOL_MIN, "AI_MEMORY_PG_POOL_MIN");
9093        assert_eq!(
9094            ENV_PG_ACQUIRE_TIMEOUT_SECS,
9095            "AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS"
9096        );
9097    }
9098
9099    #[test]
9100    fn resolve_llm_1146_compiled_default_when_nothing_configured() {
9101        let _g = env_var_lock();
9102        scrub_llm_env();
9103        let cfg = empty_app_config();
9104        let resolved = cfg.resolve_llm(None, None, None);
9105        assert_eq!(resolved.backend, "ollama");
9106        assert_eq!(resolved.model, "gemma3:4b");
9107        assert_eq!(resolved.base_url, "http://localhost:11434");
9108        assert_eq!(resolved.source, ConfigSource::CompiledDefault);
9109        assert_eq!(resolved.api_key_source, KeySource::None);
9110        assert!(resolved.api_key().is_none());
9111    }
9112
9113    #[test]
9114    fn resolve_llm_1146_env_overrides_config_section() {
9115        let _g = env_var_lock();
9116        scrub_llm_env();
9117        unsafe {
9118            std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
9119            std::env::set_var("AI_MEMORY_LLM_MODEL", "grok-99");
9120            std::env::set_var("AI_MEMORY_LLM_API_KEY", "env-key");
9121        }
9122        let mut cfg = empty_app_config();
9123        cfg.llm = Some(LlmSection {
9124            backend: Some("openai".into()),
9125            model: Some("gpt-4".into()),
9126            ..LlmSection::default()
9127        });
9128        let resolved = cfg.resolve_llm(None, None, None);
9129        assert_eq!(resolved.backend, "xai", "env must beat config");
9130        assert_eq!(resolved.model, "grok-99");
9131        assert_eq!(resolved.source, ConfigSource::Env);
9132        assert_eq!(resolved.api_key_source, KeySource::ProcessEnv);
9133        assert_eq!(resolved.api_key(), Some("env-key"));
9134        scrub_llm_env();
9135    }
9136
9137    #[test]
9138    fn resolve_llm_1146_cli_overrides_env() {
9139        let _g = env_var_lock();
9140        scrub_llm_env();
9141        unsafe {
9142            std::env::set_var("AI_MEMORY_LLM_BACKEND", "ollama");
9143            std::env::set_var("AI_MEMORY_LLM_MODEL", "ollama-model");
9144        }
9145        let cfg = empty_app_config();
9146        let resolved = cfg.resolve_llm(Some("xai"), Some("grok-4.3"), Some("https://x"));
9147        assert_eq!(resolved.backend, "xai", "CLI flag must beat env");
9148        assert_eq!(resolved.model, "grok-4.3");
9149        assert_eq!(resolved.base_url, "https://x");
9150        assert_eq!(resolved.source, ConfigSource::Cli);
9151        scrub_llm_env();
9152    }
9153
9154    #[test]
9155    fn resolve_llm_1146_config_section_when_no_env() {
9156        let _g = env_var_lock();
9157        scrub_llm_env();
9158        let mut cfg = empty_app_config();
9159        cfg.llm = Some(LlmSection {
9160            backend: Some("xai".into()),
9161            model: Some("grok-4.3".into()),
9162            ..LlmSection::default()
9163        });
9164        let resolved = cfg.resolve_llm(None, None, None);
9165        assert_eq!(resolved.backend, "xai");
9166        assert_eq!(resolved.model, "grok-4.3");
9167        assert_eq!(
9168            resolved.base_url, "https://api.x.ai/v1",
9169            "vendor-default base_url applied"
9170        );
9171        assert_eq!(resolved.source, ConfigSource::Config);
9172    }
9173
9174    #[test]
9175    fn resolve_llm_1146_tier_model_override_clobbers_config_model_1440() {
9176        // #1440 regression: the pre-fix curator `--daemon` path passed
9177        // the feature-tier's default (local-Ollama) model id as the
9178        // CLI-arm model override. Because the CLI arm is highest
9179        // precedence, it clobbered the operator's configured
9180        // `[llm].model`, sending the local default to OpenRouter ->
9181        // fast HTTP 400 on every curator call. This test pins BOTH
9182        // halves of the RCA so the bug can't silently return:
9183        //   1. With no override (the `--once` / fixed `--daemon` path),
9184        //      the configured model wins.
9185        //   2. Passing the tier-default id as the override DOES clobber
9186        //      it — which is exactly why the daemon must never do so.
9187        let _g = env_var_lock();
9188        scrub_llm_env();
9189
9190        // Each value is bound once to a named variable (no repeated
9191        // literals, no magic strings in assertions). The tier-default
9192        // model is derived from the enum so the test tracks the single
9193        // source of truth rather than asserting against a copy.
9194        let configured_backend = "openrouter";
9195        let configured_model = "google/gemma-4-26b-a4b-it";
9196        let tier_default_model = crate::config::FeatureTier::Autonomous.config().llm_model;
9197
9198        let mut cfg = empty_app_config();
9199        cfg.llm = Some(LlmSection {
9200            backend: Some(configured_backend.into()),
9201            model: Some(configured_model.into()),
9202            ..LlmSection::default()
9203        });
9204
9205        // 1. No override -> configured model is honored.
9206        let resolved = cfg.resolve_llm(None, None, None);
9207        assert_eq!(resolved.backend, configured_backend);
9208        assert_eq!(resolved.model, configured_model);
9209
9210        // 2. Tier-default id as CLI-arm override clobbers it (the bug):
9211        //    the override wins over the configured model, which is
9212        //    exactly why the daemon must never manufacture one.
9213        let tier_override = tier_default_model.expect("autonomous tier has a default llm_model");
9214        let clobbered = cfg.resolve_llm(None, Some(tier_override.as_str()), None);
9215        assert_eq!(
9216            clobbered.model, tier_override,
9217            "tier-default override wins over configured model — the #1440 daemon defect"
9218        );
9219        assert_ne!(
9220            clobbered.model, configured_model,
9221            "the override must differ from the configured model for this regression to be meaningful"
9222        );
9223        scrub_llm_env();
9224    }
9225
9226    #[test]
9227    fn resolve_llm_1146_alias_fallback_key_for_xai() {
9228        let _g = env_var_lock();
9229        scrub_llm_env();
9230        unsafe {
9231            std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
9232            std::env::set_var("XAI_API_KEY", "alias-fallback-key");
9233        }
9234        let cfg = empty_app_config();
9235        let resolved = cfg.resolve_llm(None, None, None);
9236        assert_eq!(resolved.backend, "xai");
9237        assert_eq!(resolved.api_key(), Some("alias-fallback-key"));
9238        match &resolved.api_key_source {
9239            KeySource::AliasFallback(name) => assert_eq!(name, "XAI_API_KEY"),
9240            other => panic!("expected AliasFallback(XAI_API_KEY), got {other:?}"),
9241        }
9242        scrub_llm_env();
9243    }
9244
9245    #[test]
9246    fn resolve_llm_1146_legacy_llm_model_feeds_resolver() {
9247        let _g = env_var_lock();
9248        scrub_llm_env();
9249        let mut cfg = AppConfig::default();
9250        cfg.llm_model = Some("gemma4:e4b".into());
9251        cfg.ollama_url = Some("http://localhost:11434".into());
9252        let resolved = cfg.resolve_llm(None, None, None);
9253        assert_eq!(resolved.backend, "ollama");
9254        assert_eq!(resolved.model, "gemma4:e4b");
9255        assert_eq!(resolved.source, ConfigSource::Legacy);
9256    }
9257
9258    #[test]
9259    fn validate_secret_handling_1146_rejects_inline_api_key() {
9260        let mut cfg = empty_app_config();
9261        cfg.llm = Some(LlmSection {
9262            backend: Some("xai".into()),
9263            api_key: Some("xai-INLINE-SECRET".into()),
9264            ..LlmSection::default()
9265        });
9266        let err = cfg
9267            .validate_secret_handling()
9268            .expect_err("inline api_key must be rejected");
9269        assert!(
9270            err.contains("api_key") && err.contains("forbidden"),
9271            "error must name the field and the policy: {err}"
9272        );
9273    }
9274
9275    #[test]
9276    fn validate_secret_handling_1146_rejects_env_and_file_both_set() {
9277        let mut cfg = empty_app_config();
9278        cfg.llm = Some(LlmSection {
9279            backend: Some("xai".into()),
9280            api_key_env: Some("XAI_API_KEY".into()),
9281            api_key_file: Some("/etc/key".into()),
9282            ..LlmSection::default()
9283        });
9284        let err = cfg
9285            .validate_secret_handling()
9286            .expect_err("env+file mutex must be enforced");
9287        assert!(
9288            err.contains("api_key_env") && err.contains("api_key_file"),
9289            "error must call out the mutex: {err}"
9290        );
9291    }
9292
9293    #[test]
9294    fn resolve_llm_1146_api_key_env_reads_named_env_var() {
9295        let _g = env_var_lock();
9296        scrub_llm_env();
9297        unsafe {
9298            std::env::set_var("MY_CUSTOM_LLM_KEY", "via-config-env-var");
9299        }
9300        let mut cfg = empty_app_config();
9301        cfg.llm = Some(LlmSection {
9302            backend: Some("xai".into()),
9303            model: Some("grok-4.3".into()),
9304            api_key_env: Some("MY_CUSTOM_LLM_KEY".into()),
9305            ..LlmSection::default()
9306        });
9307        let resolved = cfg.resolve_llm(None, None, None);
9308        assert_eq!(resolved.api_key(), Some("via-config-env-var"));
9309        match &resolved.api_key_source {
9310            KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_LLM_KEY"),
9311            other => panic!("expected ConfigEnvVar(MY_CUSTOM_LLM_KEY), got {other:?}"),
9312        }
9313        unsafe {
9314            std::env::remove_var("MY_CUSTOM_LLM_KEY");
9315        }
9316    }
9317
9318    #[test]
9319    #[cfg(unix)]
9320    fn resolve_llm_1146_api_key_file_rejects_lax_perms() {
9321        use std::os::unix::fs::PermissionsExt;
9322        let _g = env_var_lock();
9323        scrub_llm_env();
9324        // Tempdir under .local-runs (project HARD rule: no /tmp).
9325        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9326            .join(".local-runs")
9327            .join(format!("test-1146-perms-{}", std::process::id()));
9328        std::fs::create_dir_all(&base).unwrap();
9329        let key_path = base.join("xai.key");
9330        std::fs::write(&key_path, "shhh").unwrap();
9331        // World-readable mode 0644 — must be rejected.
9332        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
9333
9334        let mut cfg = empty_app_config();
9335        cfg.llm = Some(LlmSection {
9336            backend: Some("xai".into()),
9337            api_key_file: Some(key_path.display().to_string()),
9338            ..LlmSection::default()
9339        });
9340        let resolved = cfg.resolve_llm(None, None, None);
9341        match &resolved.api_key_source {
9342            KeySource::Error(reason) => {
9343                assert!(
9344                    reason.contains("lax permissions") && reason.contains("0400"),
9345                    "error must name the perm policy: {reason}"
9346                );
9347            }
9348            other => panic!("expected KeySource::Error(lax perms), got {other:?}"),
9349        }
9350        // Cleanup.
9351        let _ = std::fs::remove_file(&key_path);
9352        let _ = std::fs::remove_dir(&base);
9353    }
9354
9355    #[test]
9356    #[cfg(unix)]
9357    fn resolve_llm_1146_api_key_file_accepts_0400() {
9358        use std::os::unix::fs::PermissionsExt;
9359        let _g = env_var_lock();
9360        scrub_llm_env();
9361        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9362            .join(".local-runs")
9363            .join(format!("test-1146-perms-ok-{}", std::process::id()));
9364        std::fs::create_dir_all(&base).unwrap();
9365        let key_path = base.join("xai.key");
9366        std::fs::write(&key_path, "the-actual-key\n").unwrap();
9367        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o400)).unwrap();
9368
9369        let mut cfg = empty_app_config();
9370        cfg.llm = Some(LlmSection {
9371            backend: Some("xai".into()),
9372            api_key_file: Some(key_path.display().to_string()),
9373            ..LlmSection::default()
9374        });
9375        let resolved = cfg.resolve_llm(None, None, None);
9376        assert_eq!(
9377            resolved.api_key(),
9378            Some("the-actual-key"),
9379            "first line is the key"
9380        );
9381        assert!(matches!(resolved.api_key_source, KeySource::ConfigFile(_)));
9382
9383        let _ = std::fs::remove_file(&key_path);
9384        let _ = std::fs::remove_dir(&base);
9385    }
9386
9387    #[test]
9388    fn resolve_embeddings_1146_legacy_alias_canonicalised() {
9389        let _g = env_var_lock();
9390        scrub_llm_env();
9391        let mut cfg = AppConfig::default();
9392        cfg.embedding_model = Some("nomic_embed_v15".into());
9393        let resolved = cfg.resolve_embeddings();
9394        assert_eq!(
9395            resolved.model, "nomic-embed-text-v1.5",
9396            "legacy alias must be canonicalised"
9397        );
9398        assert_eq!(resolved.source, ConfigSource::Legacy);
9399        assert_eq!(resolved.backfill_batch, 100, "compiled default applied");
9400    }
9401
9402    #[test]
9403    fn resolve_embeddings_1146_backfill_batch_env_overrides_config() {
9404        let _g = env_var_lock();
9405        scrub_llm_env();
9406        unsafe {
9407            std::env::set_var("AI_MEMORY_EMBED_BACKFILL_BATCH", "500");
9408        }
9409        let mut cfg = empty_app_config();
9410        cfg.embeddings = Some(EmbeddingsSection {
9411            backfill_batch: Some(50),
9412            ..EmbeddingsSection::default()
9413        });
9414        let resolved = cfg.resolve_embeddings();
9415        assert_eq!(resolved.backfill_batch, 500, "env must beat config");
9416        scrub_llm_env();
9417    }
9418
9419    // ── #1598 — API-wired embeddings resolver ladder ──────────────────
9420
9421    #[test]
9422    fn resolve_embeddings_1598_compiled_defaults() {
9423        let _g = env_var_lock();
9424        scrub_llm_env();
9425        scrub_embed_env();
9426        let cfg = empty_app_config();
9427        let resolved = cfg.resolve_embeddings();
9428        assert_eq!(resolved.backend, crate::llm::BACKEND_OLLAMA);
9429        assert_eq!(resolved.url, crate::llm::DEFAULT_OLLAMA_URL);
9430        assert_eq!(resolved.model, DEFAULT_EMBED_MODEL);
9431        assert_eq!(resolved.source, ConfigSource::CompiledDefault);
9432        assert_eq!(resolved.api_key(), None);
9433        assert_eq!(resolved.key_source, KeySource::None);
9434    }
9435
9436    #[test]
9437    fn resolve_embeddings_1598_env_beats_section() {
9438        let _g = env_var_lock();
9439        scrub_llm_env();
9440        scrub_embed_env();
9441        unsafe {
9442            std::env::set_var(ENV_EMBED_BACKEND, "openai-compatible");
9443            std::env::set_var(ENV_EMBED_BASE_URL, "http://tei.internal:8080/v1");
9444            std::env::set_var(
9445                ENV_EMBED_MODEL,
9446                "ibm-granite/granite-embedding-125m-english",
9447            );
9448        }
9449        let mut cfg = empty_app_config();
9450        cfg.embeddings = Some(EmbeddingsSection {
9451            backend: Some("ollama".into()),
9452            url: Some("http://section-url:11434".into()),
9453            model: Some("nomic-embed-text-v1.5".into()),
9454            ..EmbeddingsSection::default()
9455        });
9456        let resolved = cfg.resolve_embeddings();
9457        assert_eq!(resolved.backend, "openai-compatible");
9458        assert_eq!(resolved.url, "http://tei.internal:8080/v1");
9459        assert_eq!(resolved.model, "ibm-granite/granite-embedding-125m-english");
9460        assert_eq!(resolved.source, ConfigSource::Env);
9461        assert_eq!(
9462            resolved.embedding_dim,
9463            Some(768),
9464            "granite dim comes from the known-dims table"
9465        );
9466        scrub_embed_env();
9467    }
9468
9469    #[test]
9470    fn resolve_embeddings_1598_section_beats_legacy() {
9471        let _g = env_var_lock();
9472        scrub_llm_env();
9473        scrub_embed_env();
9474        let mut cfg = empty_app_config();
9475        cfg.embed_url = Some("http://legacy-embed:11434".into());
9476        cfg.embedding_model = Some("mini_lm_l6_v2".into());
9477        cfg.embeddings = Some(EmbeddingsSection {
9478            url: Some("http://section:11434".into()),
9479            model: Some("nomic-embed-text-v1.5".into()),
9480            ..EmbeddingsSection::default()
9481        });
9482        let resolved = cfg.resolve_embeddings();
9483        assert_eq!(resolved.url, "http://section:11434");
9484        assert_eq!(resolved.model, "nomic-embed-text-v1.5");
9485        assert_eq!(resolved.source, ConfigSource::Config);
9486    }
9487
9488    #[test]
9489    fn resolve_embeddings_1598_base_url_wins_over_url_synonym() {
9490        let _g = env_var_lock();
9491        scrub_llm_env();
9492        scrub_embed_env();
9493        let mut cfg = empty_app_config();
9494        cfg.embeddings = Some(EmbeddingsSection {
9495            base_url: Some("http://base-url-wins:8080/v1".into()),
9496            url: Some("http://url-loses:11434".into()),
9497            ..EmbeddingsSection::default()
9498        });
9499        let resolved = cfg.resolve_embeddings();
9500        assert_eq!(resolved.url, "http://base-url-wins:8080/v1");
9501    }
9502
9503    #[test]
9504    fn resolve_embeddings_1598_api_alias_default_base_url() {
9505        let _g = env_var_lock();
9506        scrub_llm_env();
9507        scrub_embed_env();
9508        let mut cfg = empty_app_config();
9509        cfg.embeddings = Some(EmbeddingsSection {
9510            backend: Some("openrouter".into()),
9511            model: Some("google/gemini-embedding-2".into()),
9512            ..EmbeddingsSection::default()
9513        });
9514        let resolved = cfg.resolve_embeddings();
9515        assert_eq!(
9516            resolved.url, "https://openrouter.ai/api/v1",
9517            "API alias with no URL configured must fall back to the \
9518             vendor default from llm.rs"
9519        );
9520        assert_eq!(resolved.embedding_dim, Some(3072), "gemini-embedding-2 dim");
9521    }
9522
9523    #[test]
9524    fn resolve_embeddings_1598_dim_override_beats_table() {
9525        let _g = env_var_lock();
9526        scrub_llm_env();
9527        scrub_embed_env();
9528        let mut cfg = empty_app_config();
9529        cfg.embeddings = Some(EmbeddingsSection {
9530            model: Some("nomic-embed-text-v1.5".into()),
9531            dim: Some(512),
9532            ..EmbeddingsSection::default()
9533        });
9534        let resolved = cfg.resolve_embeddings();
9535        assert_eq!(
9536            resolved.embedding_dim,
9537            Some(512),
9538            "[embeddings].dim override must beat the known-dims table"
9539        );
9540        // Non-positive override is ignored — table wins again.
9541        cfg.embeddings = Some(EmbeddingsSection {
9542            model: Some("nomic-embed-text-v1.5".into()),
9543            dim: Some(0),
9544            ..EmbeddingsSection::default()
9545        });
9546        assert_eq!(cfg.resolve_embeddings().embedding_dim, Some(768));
9547    }
9548
9549    /// #1598 fleet follow-up — `requested_dim` carries ONLY the
9550    /// explicit `[embeddings].dim` (the wire `dimensions` request for
9551    /// Matryoshka-capable API models); a table-derived dim must never
9552    /// populate it, and non-positive overrides are ignored.
9553    #[test]
9554    fn resolve_embeddings_1598_requested_dim_explicit_only() {
9555        let _g = env_var_lock();
9556        scrub_llm_env();
9557        scrub_embed_env();
9558        let mut cfg = empty_app_config();
9559        // Table-known model, no explicit dim → requested_dim None.
9560        cfg.embeddings = Some(EmbeddingsSection {
9561            model: Some("nomic-embed-text-v1.5".into()),
9562            ..EmbeddingsSection::default()
9563        });
9564        let resolved = cfg.resolve_embeddings();
9565        assert_eq!(resolved.embedding_dim, Some(768), "table dim resolves");
9566        assert_eq!(
9567            resolved.requested_dim, None,
9568            "table-derived dim must not become a wire dimensions request"
9569        );
9570        // Explicit dim → both embedding_dim and requested_dim.
9571        cfg.embeddings = Some(EmbeddingsSection {
9572            model: Some("google/gemini-embedding-2".into()),
9573            dim: Some(768),
9574            ..EmbeddingsSection::default()
9575        });
9576        let resolved = cfg.resolve_embeddings();
9577        assert_eq!(resolved.embedding_dim, Some(768));
9578        assert_eq!(resolved.requested_dim, Some(768));
9579        // Non-positive explicit dim is ignored on both fields.
9580        cfg.embeddings = Some(EmbeddingsSection {
9581            model: Some("google/gemini-embedding-2".into()),
9582            dim: Some(0),
9583            ..EmbeddingsSection::default()
9584        });
9585        let resolved = cfg.resolve_embeddings();
9586        assert_eq!(resolved.embedding_dim, Some(3072), "table dim again");
9587        assert_eq!(resolved.requested_dim, None);
9588    }
9589
9590    #[test]
9591    fn resolve_embed_api_key_1598_process_env_wins() {
9592        let _g = env_var_lock();
9593        scrub_llm_env();
9594        scrub_embed_env();
9595        unsafe {
9596            std::env::set_var(ENV_EMBED_API_KEY, "embed-process-env-key");
9597            std::env::set_var("OPENROUTER_API_KEY", "alias-key-loses");
9598        }
9599        let mut cfg = empty_app_config();
9600        cfg.embeddings = Some(EmbeddingsSection {
9601            backend: Some("openrouter".into()),
9602            ..EmbeddingsSection::default()
9603        });
9604        let resolved = cfg.resolve_embeddings();
9605        assert_eq!(resolved.api_key(), Some("embed-process-env-key"));
9606        assert_eq!(resolved.key_source, KeySource::ProcessEnv);
9607        scrub_embed_env();
9608    }
9609
9610    #[test]
9611    fn resolve_embed_api_key_1598_alias_fallback() {
9612        let _g = env_var_lock();
9613        scrub_llm_env();
9614        scrub_embed_env();
9615        unsafe {
9616            std::env::set_var("OPENROUTER_API_KEY", "alias-fallback-embed-key");
9617        }
9618        let mut cfg = empty_app_config();
9619        cfg.embeddings = Some(EmbeddingsSection {
9620            backend: Some("openrouter".into()),
9621            ..EmbeddingsSection::default()
9622        });
9623        let resolved = cfg.resolve_embeddings();
9624        assert_eq!(resolved.api_key(), Some("alias-fallback-embed-key"));
9625        match &resolved.key_source {
9626            KeySource::AliasFallback(name) => assert_eq!(name, "OPENROUTER_API_KEY"),
9627            other => panic!("expected AliasFallback(OPENROUTER_API_KEY), got {other:?}"),
9628        }
9629        scrub_embed_env();
9630    }
9631
9632    #[test]
9633    fn resolve_embed_api_key_1598_config_env_var() {
9634        let _g = env_var_lock();
9635        scrub_llm_env();
9636        scrub_embed_env();
9637        unsafe {
9638            std::env::set_var("MY_CUSTOM_EMBED_KEY", "via-embed-config-env-var");
9639        }
9640        let mut cfg = empty_app_config();
9641        cfg.embeddings = Some(EmbeddingsSection {
9642            backend: Some("openai-compatible".into()),
9643            api_key_env: Some("MY_CUSTOM_EMBED_KEY".into()),
9644            ..EmbeddingsSection::default()
9645        });
9646        let resolved = cfg.resolve_embeddings();
9647        assert_eq!(resolved.api_key(), Some("via-embed-config-env-var"));
9648        match &resolved.key_source {
9649            KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_EMBED_KEY"),
9650            other => panic!("expected ConfigEnvVar(MY_CUSTOM_EMBED_KEY), got {other:?}"),
9651        }
9652        unsafe {
9653            std::env::remove_var("MY_CUSTOM_EMBED_KEY");
9654        }
9655    }
9656
9657    #[test]
9658    #[cfg(unix)]
9659    fn resolve_embed_api_key_1598_api_key_file_rejects_lax_perms() {
9660        use std::os::unix::fs::PermissionsExt;
9661        let _g = env_var_lock();
9662        scrub_llm_env();
9663        scrub_embed_env();
9664        let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9665            .join(".local-runs")
9666            .join(format!("test-1598-perms-lax-{}", std::process::id()));
9667        std::fs::create_dir_all(&base).unwrap();
9668        let key_path = base.join("embed.key");
9669        std::fs::write(&key_path, "leaky-embed-key\n").unwrap();
9670        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
9671
9672        let mut cfg = empty_app_config();
9673        cfg.embeddings = Some(EmbeddingsSection {
9674            backend: Some("openai-compatible".into()),
9675            api_key_file: Some(key_path.display().to_string()),
9676            ..EmbeddingsSection::default()
9677        });
9678        let resolved = cfg.resolve_embeddings();
9679        assert_eq!(resolved.api_key(), None, "lax-perm file must be refused");
9680        match &resolved.key_source {
9681            KeySource::Error(reason) => {
9682                assert!(
9683                    reason.contains("[embeddings].api_key_file") && reason.contains("lax"),
9684                    "error must attribute the embeddings field: {reason}"
9685                );
9686            }
9687            other => panic!("expected KeySource::Error, got {other:?}"),
9688        }
9689
9690        let _ = std::fs::remove_file(&key_path);
9691        let _ = std::fs::remove_dir(&base);
9692    }
9693
9694    #[test]
9695    fn resolved_embeddings_1598_debug_redacts_api_key() {
9696        let _g = env_var_lock();
9697        scrub_llm_env();
9698        scrub_embed_env();
9699        unsafe {
9700            std::env::set_var(ENV_EMBED_API_KEY, "super-secret-embed-key");
9701        }
9702        let mut cfg = empty_app_config();
9703        cfg.embeddings = Some(EmbeddingsSection {
9704            backend: Some("openrouter".into()),
9705            ..EmbeddingsSection::default()
9706        });
9707        let resolved = cfg.resolve_embeddings();
9708        let debugged = format!("{resolved:?}");
9709        assert!(
9710            !debugged.contains("super-secret-embed-key"),
9711            "Debug must never leak the key: {debugged}"
9712        );
9713        assert!(
9714            debugged.contains(crate::REDACTED_PLACEHOLDER),
9715            "Debug must show the redaction placeholder: {debugged}"
9716        );
9717        scrub_embed_env();
9718    }
9719
9720    #[test]
9721    fn validate_secret_handling_1598_rejects_inline_embeddings_api_key() {
9722        let mut cfg = empty_app_config();
9723        cfg.embeddings = Some(EmbeddingsSection {
9724            backend: Some("openrouter".into()),
9725            api_key: Some("embed-INLINE-SECRET".into()),
9726            ..EmbeddingsSection::default()
9727        });
9728        let err = cfg
9729            .validate_secret_handling()
9730            .expect_err("inline [embeddings].api_key must be rejected");
9731        assert!(
9732            err.contains("api_key") && err.contains("forbidden") && err.contains("[embeddings]"),
9733            "error must name the field, section, and policy: {err}"
9734        );
9735    }
9736
9737    #[test]
9738    fn validate_secret_handling_1598_rejects_embeddings_env_and_file_both_set() {
9739        let mut cfg = empty_app_config();
9740        cfg.embeddings = Some(EmbeddingsSection {
9741            api_key_env: Some("EMBED_KEY".into()),
9742            api_key_file: Some("/etc/embed.key".into()),
9743            ..EmbeddingsSection::default()
9744        });
9745        let err = cfg
9746            .validate_secret_handling()
9747            .expect_err("[embeddings] env+file mutex must be enforced");
9748        assert!(
9749            err.contains("[embeddings].api_key_env") && err.contains("[embeddings].api_key_file"),
9750            "error must call out the mutex: {err}"
9751        );
9752    }
9753
9754    #[test]
9755    fn is_api_embed_backend_1598_classification() {
9756        // "ollama" is the ONLY non-API backend (case/space tolerant).
9757        assert!(!is_api_embed_backend(crate::llm::BACKEND_OLLAMA));
9758        assert!(!is_api_embed_backend(" Ollama "));
9759        // Every #1067 alias + the generic escape hatch is an API backend.
9760        for api in ["openrouter", "openai", "gemini", "openai-compatible"] {
9761            assert!(is_api_embed_backend(api), "{api} must classify as API");
9762        }
9763    }
9764
9765    #[test]
9766    fn known_embedding_dims_1598_gemini_and_granite_entries() {
9767        assert_eq!(
9768            canonical_embedding_dim("google/gemini-embedding-2"),
9769            Some(3072)
9770        );
9771        assert_eq!(canonical_embedding_dim("gemini-embedding-2"), Some(3072));
9772        assert_eq!(
9773            canonical_embedding_dim("ibm-granite/granite-embedding-125m-english"),
9774            Some(768)
9775        );
9776        assert_eq!(canonical_embedding_dim("granite-embedding"), Some(768));
9777    }
9778
9779    // ── #1579 B7 — `[storage].db_mmap_size_bytes` / AI_MEMORY_DB_MMAP_SIZE ──
9780
9781    #[test]
9782    fn resolve_storage_1579_mmap_compiled_default() {
9783        let _g = env_var_lock();
9784        unsafe {
9785            std::env::remove_var(ENV_DB_MMAP_SIZE);
9786        }
9787        let cfg = empty_app_config();
9788        let resolved = cfg.resolve_storage();
9789        assert_eq!(
9790            resolved.db_mmap_size_bytes,
9791            crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
9792            "no env + no section must bottom out on the compiled 256 MiB default"
9793        );
9794    }
9795
9796    #[test]
9797    fn resolve_storage_1579_mmap_env_overrides_config() {
9798        let _g = env_var_lock();
9799        unsafe {
9800            std::env::set_var(ENV_DB_MMAP_SIZE, "1048576");
9801        }
9802        let mut cfg = empty_app_config();
9803        cfg.storage = Some(StorageSection {
9804            db_mmap_size_bytes: Some(2_097_152),
9805            ..StorageSection::default()
9806        });
9807        let resolved = cfg.resolve_storage();
9808        assert_eq!(
9809            resolved.db_mmap_size_bytes, 1_048_576,
9810            "env must beat the [storage] section"
9811        );
9812        unsafe {
9813            std::env::remove_var(ENV_DB_MMAP_SIZE);
9814        }
9815    }
9816
9817    #[test]
9818    fn resolve_storage_1579_mmap_config_zero_disables() {
9819        let _g = env_var_lock();
9820        unsafe {
9821            std::env::remove_var(ENV_DB_MMAP_SIZE);
9822        }
9823        let mut cfg = empty_app_config();
9824        cfg.storage = Some(StorageSection {
9825            db_mmap_size_bytes: Some(0),
9826            ..StorageSection::default()
9827        });
9828        let resolved = cfg.resolve_storage();
9829        assert_eq!(
9830            resolved.db_mmap_size_bytes, 0,
9831            "explicit 0 (mmap disabled) is a deliberate operator choice and must be honoured"
9832        );
9833    }
9834
9835    #[test]
9836    fn resolve_storage_1579_mmap_garbage_falls_through() {
9837        let _g = env_var_lock();
9838        unsafe {
9839            std::env::set_var(ENV_DB_MMAP_SIZE, "not-a-number");
9840        }
9841        let mut cfg = empty_app_config();
9842        cfg.storage = Some(StorageSection {
9843            db_mmap_size_bytes: Some(-5),
9844            ..StorageSection::default()
9845        });
9846        let resolved = cfg.resolve_storage();
9847        assert_eq!(
9848            resolved.db_mmap_size_bytes,
9849            crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
9850            "unparseable env + negative section value must both fall through to the compiled default"
9851        );
9852        unsafe {
9853            std::env::remove_var(ENV_DB_MMAP_SIZE);
9854        }
9855    }
9856
9857    // ── #1590 — `[storage].default_namespace` explicit-vs-compiled provenance ──
9858
9859    /// #1590 regression — `resolve_storage` distinguishes an EXPLICIT
9860    /// operator `default_namespace` (section or legacy flat field)
9861    /// from the compiled `"global"` fallback, and
9862    /// `explicit_default_namespace()` only reports the former.
9863    #[test]
9864    fn resolve_storage_default_namespace_provenance_1590() {
9865        let _g = env_var_lock();
9866        // Unconfigured: compiled default, NOT explicit.
9867        let cfg = empty_app_config();
9868        let resolved = cfg.resolve_storage();
9869        assert_eq!(resolved.default_namespace, crate::DEFAULT_NAMESPACE);
9870        assert_eq!(
9871            resolved.default_namespace_source,
9872            ConfigSource::CompiledDefault
9873        );
9874        assert_eq!(resolved.explicit_default_namespace(), None);
9875
9876        // A [storage] section WITHOUT default_namespace is still NOT
9877        // explicit (the section-level `source` tag says Config, which
9878        // is exactly why the per-field tag exists).
9879        let mut cfg = empty_app_config();
9880        cfg.storage = Some(StorageSection {
9881            archive_on_gc: Some(true),
9882            ..StorageSection::default()
9883        });
9884        let resolved = cfg.resolve_storage();
9885        assert_eq!(resolved.explicit_default_namespace(), None);
9886        assert_eq!(
9887            resolved.default_namespace_source,
9888            ConfigSource::CompiledDefault
9889        );
9890
9891        // Explicit [storage].default_namespace → Config provenance.
9892        let mut cfg = empty_app_config();
9893        cfg.storage = Some(StorageSection {
9894            default_namespace: Some("alphaone".to_string()),
9895            ..StorageSection::default()
9896        });
9897        let resolved = cfg.resolve_storage();
9898        assert_eq!(resolved.default_namespace, "alphaone");
9899        assert_eq!(resolved.default_namespace_source, ConfigSource::Config);
9900        assert_eq!(resolved.explicit_default_namespace(), Some("alphaone"));
9901
9902        // Legacy flat field → Legacy provenance, still explicit.
9903        #[allow(deprecated)]
9904        let resolved = {
9905            let mut cfg = empty_app_config();
9906            cfg.default_namespace = Some("legacy-ns".to_string());
9907            cfg.resolve_storage()
9908        };
9909        assert_eq!(resolved.default_namespace, "legacy-ns");
9910        assert_eq!(resolved.default_namespace_source, ConfigSource::Legacy);
9911        assert_eq!(resolved.explicit_default_namespace(), Some("legacy-ns"));
9912
9913        // Whitespace-only is treated as unset (not explicit).
9914        let mut cfg = empty_app_config();
9915        cfg.storage = Some(StorageSection {
9916            default_namespace: Some("   ".to_string()),
9917            ..StorageSection::default()
9918        });
9919        let resolved = cfg.resolve_storage();
9920        assert_eq!(resolved.explicit_default_namespace(), None);
9921    }
9922
9923    /// #1590 regression — the process-wide seeded slot round-trips,
9924    /// filters blank values, and clears back to the unconfigured state.
9925    #[test]
9926    fn configured_default_namespace_seed_and_clear_1590() {
9927        let _gate = lock_configured_default_namespace_for_test();
9928        set_configured_default_namespace(Some("alphaone".to_string()));
9929        assert_eq!(
9930            configured_default_namespace().as_deref(),
9931            Some("alphaone"),
9932            "seeded value must be readable process-wide"
9933        );
9934        set_configured_default_namespace(Some("  ".to_string()));
9935        assert_eq!(
9936            configured_default_namespace(),
9937            None,
9938            "blank seeds are filtered to the unconfigured state"
9939        );
9940        set_configured_default_namespace(Some("ns2".to_string()));
9941        set_configured_default_namespace(None);
9942        assert_eq!(configured_default_namespace(), None, "clear resets");
9943    }
9944
9945    #[test]
9946    fn resolve_reranker_1146_folds_legacy_cross_encoder() {
9947        let _g = env_var_lock();
9948        let mut cfg = AppConfig::default();
9949        cfg.cross_encoder = Some(true);
9950        let resolved = cfg.resolve_reranker();
9951        assert!(resolved.enabled);
9952        assert_eq!(resolved.model, "ms-marco-MiniLM-L-6-v2");
9953        assert_eq!(resolved.source, ConfigSource::Legacy);
9954    }
9955
9956    /// #1604 — rerank sequence-cap ladder: env >
9957    /// `[reranker].max_seq_tokens` > compiled default, with zero /
9958    /// unparseable / above-model-ceiling values falling through.
9959    #[test]
9960    fn resolve_reranker_1604_max_seq_ladder() {
9961        let _g = env_var_lock();
9962        unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
9963
9964        // Compiled default when nothing is configured.
9965        let cfg = AppConfig::default();
9966        assert_eq!(
9967            cfg.resolve_reranker().max_seq_tokens,
9968            crate::reranker::RERANK_MAX_SEQ_DEFAULT
9969        );
9970
9971        // Config layer wins over the compiled default.
9972        let mut cfg = AppConfig::default();
9973        cfg.reranker = Some(RerankerSection {
9974            max_seq_tokens: Some(128),
9975            ..RerankerSection::default()
9976        });
9977        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
9978
9979        // Env wins over config.
9980        unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "192") };
9981        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 192);
9982
9983        // Garbage env falls through to config.
9984        unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "not-a-number") };
9985        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
9986
9987        // Zero env falls through to config.
9988        unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "0") };
9989        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
9990
9991        // Above the model ceiling falls through to config.
9992        unsafe {
9993            std::env::set_var(
9994                ENV_RERANK_MAX_SEQ,
9995                (crate::reranker::CROSS_ENCODER_MAX_SEQ + 1).to_string(),
9996            );
9997        }
9998        assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
9999
10000        // Above-ceiling CONFIG value falls through to the compiled
10001        // default (no admissible layer remains).
10002        unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10003        let mut cfg = AppConfig::default();
10004        cfg.reranker = Some(RerankerSection {
10005            max_seq_tokens: Some(crate::reranker::CROSS_ENCODER_MAX_SEQ + 1),
10006            ..RerankerSection::default()
10007        });
10008        assert_eq!(
10009            cfg.resolve_reranker().max_seq_tokens,
10010            crate::reranker::RERANK_MAX_SEQ_DEFAULT
10011        );
10012
10013        unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10014    }
10015
10016    #[test]
10017    fn resolved_llm_1146_debug_redacts_api_key() {
10018        let resolved = ResolvedLlm {
10019            backend: "xai".into(),
10020            model: "grok-4.3".into(),
10021            base_url: "https://api.x.ai/v1".into(),
10022            api_key: Some("SUPER-SECRET-DONT-LEAK".into()),
10023            api_key_source: KeySource::ProcessEnv,
10024            source: ConfigSource::Env,
10025        };
10026        let dbg = format!("{resolved:?}");
10027        assert!(
10028            !dbg.contains("SUPER-SECRET-DONT-LEAK"),
10029            "Debug impl must redact the api_key: {dbg}"
10030        );
10031        assert!(
10032            dbg.contains("<redacted>"),
10033            "Debug impl must show <redacted> placeholder: {dbg}"
10034        );
10035    }
10036
10037    /// #1454 (SEC, LOW) — a `{:?}` of an `AppConfig` carrying the HTTP
10038    /// `api_key` MUST NOT echo the secret. `skip_serializing` only
10039    /// guarded the serde JSON path; the derived `Debug` leaked it. The
10040    /// manual `Debug` impl redacts the field while preserving the rest.
10041    #[test]
10042    fn app_config_1454_debug_redacts_api_key() {
10043        let cfg = AppConfig {
10044            tier: Some("autonomous".into()),
10045            api_key: Some("HTTP-BEARER-SUPER-SECRET".into()),
10046            ..AppConfig::default()
10047        };
10048        let dbg = format!("{cfg:?}");
10049        assert!(
10050            !dbg.contains("HTTP-BEARER-SUPER-SECRET"),
10051            "AppConfig Debug must redact api_key: {dbg}"
10052        );
10053        assert!(
10054            dbg.contains("<redacted>"),
10055            "AppConfig Debug must show <redacted> placeholder: {dbg}"
10056        );
10057        // Non-secret fields still render so the impl stays useful.
10058        assert!(
10059            dbg.contains("autonomous"),
10060            "AppConfig Debug must still render non-secret fields: {dbg}"
10061        );
10062    }
10063
10064    /// #1454 (SEC, LOW) — a `{:?}` of an `LlmSection` carrying an
10065    /// inline (parse-time-rejected, but still constructable in-memory)
10066    /// `api_key` MUST redact it; the env-var-name / file-path reference
10067    /// fields stay verbatim because they are not secrets.
10068    #[test]
10069    fn llm_section_1454_debug_redacts_api_key() {
10070        let section = LlmSection {
10071            backend: Some("xai".into()),
10072            api_key: Some("LLM-INLINE-SUPER-SECRET".into()),
10073            api_key_env: Some("XAI_API_KEY".into()),
10074            ..LlmSection::default()
10075        };
10076        let dbg = format!("{section:?}");
10077        assert!(
10078            !dbg.contains("LLM-INLINE-SUPER-SECRET"),
10079            "LlmSection Debug must redact api_key: {dbg}"
10080        );
10081        assert!(
10082            dbg.contains("<redacted>"),
10083            "LlmSection Debug must show <redacted> placeholder: {dbg}"
10084        );
10085        assert!(
10086            dbg.contains("XAI_API_KEY"),
10087            "api_key_env (a name, not a secret) must stay verbatim: {dbg}"
10088        );
10089    }
10090}