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}