ai_memory/config.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7use crate::models::Tier;
8
9// ---------------------------------------------------------------------------
10// Embedding models
11// ---------------------------------------------------------------------------
12
13/// Supported embedding models for semantic search.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum EmbeddingModel {
17 /// sentence-transformers/all-MiniLM-L6-v2 — 384-dim, ~90 MB
18 MiniLmL6V2,
19 /// nomic-ai/nomic-embed-text-v1.5 — 768-dim, ~270 MB
20 NomicEmbedV15,
21}
22
23impl std::str::FromStr for EmbeddingModel {
24 type Err = String;
25
26 /// Parse the snake_case wire form used by `AppConfig.embedding_model`
27 /// (the documented top-level override). Accepts case-insensitive input
28 /// with surrounding whitespace trimmed. Keep this in sync with the
29 /// `#[serde(rename_all = "snake_case")]` variants above.
30 fn from_str(s: &str) -> Result<Self, Self::Err> {
31 match s.trim().to_ascii_lowercase().as_str() {
32 "mini_lm_l6_v2" => Ok(Self::MiniLmL6V2),
33 "nomic_embed_v15" => Ok(Self::NomicEmbedV15),
34 other => Err(format!(
35 "unknown embedding_model {other:?}: expected one of \
36 \"mini_lm_l6_v2\", \"nomic_embed_v15\""
37 )),
38 }
39 }
40}
41
42impl EmbeddingModel {
43 /// Embedding vector dimensionality.
44 pub fn dim(self) -> usize {
45 match self {
46 Self::MiniLmL6V2 => 384,
47 Self::NomicEmbedV15 => 768,
48 }
49 }
50
51 /// `HuggingFace` model identifier.
52 pub fn hf_model_id(&self) -> &str {
53 match self {
54 Self::MiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
55 Self::NomicEmbedV15 => "nomic-ai/nomic-embed-text-v1.5",
56 }
57 }
58
59 /// Canonical-id aliases recognised by [`Self::from_canonical_id`]:
60 /// the snake wire form ([`FromStr`]), the HF id (also the
61 /// [`canonicalise_embedding_model`] output), the unprefixed
62 /// shortname, and the Ollama tag. Centralising the alias strings
63 /// here keeps the model-id literals in one place (#1521).
64 fn canonical_aliases(self) -> &'static [&'static str] {
65 match self {
66 Self::MiniLmL6V2 => MINILM_CANONICAL_ALIASES,
67 Self::NomicEmbedV15 => NOMIC_CANONICAL_ALIASES,
68 }
69 }
70
71 /// Parse any recognised canonical id form — the snake wire form, the
72 /// HF id, the unprefixed shortname, or the Ollama tag — into a
73 /// daemon-constructible model. Returns `None` for ids the 2-model
74 /// daemon embedder cannot build (e.g. `bge-large-en`); callers fall
75 /// back to the tier preset. Case-insensitive; surrounding whitespace
76 /// is trimmed (#1521).
77 ///
78 /// Unlike [`FromStr`] (which only accepts the snake wire form), this
79 /// also accepts whatever an operator wrote in `[embeddings].model`
80 /// after [`canonicalise_embedding_model`], so the sectioned config
81 /// block drives the daemon embedder.
82 #[must_use]
83 pub fn from_canonical_id(s: &str) -> Option<Self> {
84 let needle = s.trim();
85 if needle.is_empty() {
86 return None;
87 }
88 [Self::MiniLmL6V2, Self::NomicEmbedV15]
89 .into_iter()
90 .find(|model| {
91 model
92 .canonical_aliases()
93 .iter()
94 .any(|alias| alias.eq_ignore_ascii_case(needle))
95 })
96 }
97}
98
99/// Canonical-id aliases for [`EmbeddingModel::MiniLmL6V2`] — snake wire
100/// form, HF id ([`canonicalise_embedding_model`] output), unprefixed
101/// shortname, Ollama tag. See [`EmbeddingModel::from_canonical_id`].
102const MINILM_CANONICAL_ALIASES: &[&str] = &[
103 "mini_lm_l6_v2",
104 "sentence-transformers/all-MiniLM-L6-v2",
105 "all-MiniLM-L6-v2",
106 "all-minilm",
107];
108
109/// Canonical-id aliases for [`EmbeddingModel::NomicEmbedV15`] — snake
110/// wire form, HF id ([`canonicalise_embedding_model`] output), Ollama
111/// tag, prefixed HF id. See [`EmbeddingModel::from_canonical_id`].
112const NOMIC_CANONICAL_ALIASES: &[&str] = &[
113 "nomic_embed_v15",
114 "nomic-embed-text-v1.5",
115 "nomic-embed-text",
116 "nomic-ai/nomic-embed-text-v1.5",
117];
118
119// ---------------------------------------------------------------------------
120// Config key names
121// ---------------------------------------------------------------------------
122
123/// Canonical name strings for the legacy v1 flat config keys (plus the
124/// `[embeddings]` section name) that appear on multiple production
125/// sites (#1558). Shared between the `AppConfig` surface in this file
126/// (the manual `Debug` impl + `warn_unknown_top_level_keys`) and the
127/// `ai-memory config migrate` rewriter in
128/// `src/cli/commands/config.rs`, so each key spelling has one source
129/// of truth. The serde wire names themselves derive from the
130/// `AppConfig` field identifiers (no `#[serde(rename)]`), so serde
131/// needs no literal at all.
132pub mod config_keys {
133 /// Legacy flat `archive_max_days` key (v2: `[storage].archive_max_days`).
134 pub const ARCHIVE_MAX_DAYS: &str = "archive_max_days";
135 /// Legacy flat `archive_on_gc` key (v2: `[storage].archive_on_gc`).
136 pub const ARCHIVE_ON_GC: &str = "archive_on_gc";
137 /// Legacy flat `auto_tag_model` key (v2: `[llm.auto_tag].model`).
138 pub const AUTO_TAG_MODEL: &str = "auto_tag_model";
139 /// Legacy flat `cross_encoder` key (v2: `[reranker].enabled`).
140 pub const CROSS_ENCODER: &str = "cross_encoder";
141 /// Legacy flat `default_namespace` key (v2: `[storage].default_namespace`).
142 pub const DEFAULT_NAMESPACE: &str = "default_namespace";
143 /// Legacy flat `embedding_model` key (v2: `[embeddings].model`).
144 pub const EMBEDDING_MODEL: &str = "embedding_model";
145 /// Legacy flat `max_memory_mb` key (v2: resolved via `[storage]`).
146 pub const MAX_MEMORY_MB: &str = "max_memory_mb";
147 /// Legacy flat `ollama_url` key (v2: `[llm].base_url` / `[embeddings].url`).
148 pub const OLLAMA_URL: &str = "ollama_url";
149 /// `[embeddings]` config-section name (#1146 sectioned schema).
150 pub const SECTION_EMBEDDINGS: &str = "embeddings";
151}
152
153// ---------------------------------------------------------------------------
154// LLM model defaults
155// ---------------------------------------------------------------------------
156
157/// Provider-agnostic default backend LLM model tag for the LLM-capable
158/// feature tiers (smart / autonomous).
159///
160/// The NAME is vendor-agnostic by design (#1067 / #1146 / #1490): ai-memory
161/// speaks to ANY backend — local Ollama, OpenAI, Anthropic, xAI, Gemini,
162/// Groq, OpenRouter, or any OpenAI-compatible endpoint — selected via the
163/// `[llm]` config section or `AI_MEMORY_LLM_*` env vars. The VALUE returned
164/// here is only the compiled fallback used when no model is configured at any
165/// precedence layer; it is identical to [`backend_default_model`]'s catch-all
166/// arm (the single source of truth for the local-Ollama default tag) and is
167/// overridden at every layer in the resolver ladder
168/// (CLI > env > `[llm]` > legacy flat field > this compiled default).
169///
170/// No vendor/model name is baked into any tier-config identifier — the tier
171/// presets carry this resolved default string, not a model-named enum.
172#[must_use]
173pub fn default_tier_llm_model() -> &'static str {
174 backend_default_model(crate::llm::BACKEND_OLLAMA)
175}
176
177// ---------------------------------------------------------------------------
178// Feature tiers
179// ---------------------------------------------------------------------------
180
181/// Feature tiers control which AI capabilities are active based on the
182/// available memory budget on the host machine.
183///
184/// # Disambiguation (issue #970)
185///
186/// The codebase has three enums whose names end in `Tier`.
187/// `FeatureTier` (this enum) is the **host capability tier** that
188/// gates which AI features fit in RAM (0 / 256 MB / 1 GB / 4 GB). It
189/// is unrelated to:
190///
191/// - [`crate::models::Tier`] — memory-lifecycle TTL bucket
192/// (Short/Mid/Long).
193/// - [`crate::models::ConfidenceTier`] — confidence-value bucket
194/// (Confirmed/Likely/Ambiguous).
195///
196/// They do not share variants, wire strings, or call sites. See
197/// `docs/internal/enum-proliferation-audit-970.md`.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum FeatureTier {
201 /// FTS5 keyword search only — 0 MB extra.
202 Keyword,
203 /// `MiniLM` embeddings + HNSW index — ~256 MB.
204 Semantic,
205 /// nomic-embed + a backend LLM (any configured provider) — ~1 GB.
206 Smart,
207 /// nomic-embed + a backend LLM (any configured provider) + cross-encoder — ~4 GB.
208 Autonomous,
209}
210
211impl FeatureTier {
212 /// Parse a tier name (case-insensitive).
213 pub fn from_str(s: &str) -> Option<Self> {
214 match s.to_ascii_lowercase().as_str() {
215 "keyword" => Some(Self::Keyword),
216 "semantic" => Some(Self::Semantic),
217 "smart" => Some(Self::Smart),
218 "autonomous" => Some(Self::Autonomous),
219 _ => None,
220 }
221 }
222
223 /// Canonical lowercase name.
224 pub fn as_str(&self) -> &str {
225 match self {
226 Self::Keyword => "keyword",
227 Self::Semantic => "semantic",
228 Self::Smart => "smart",
229 Self::Autonomous => "autonomous",
230 }
231 }
232
233 /// Build the full [`TierConfig`] for this tier.
234 pub fn config(self) -> TierConfig {
235 match self {
236 Self::Keyword => TierConfig {
237 tier: self,
238 embedding_model: None,
239 llm_model: None,
240 cross_encoder: false,
241 max_memory_mb: 0,
242 },
243 Self::Semantic => TierConfig {
244 tier: self,
245 embedding_model: Some(EmbeddingModel::MiniLmL6V2),
246 llm_model: None,
247 cross_encoder: false,
248 max_memory_mb: 256,
249 },
250 Self::Smart => TierConfig {
251 tier: self,
252 embedding_model: Some(EmbeddingModel::NomicEmbedV15),
253 llm_model: Some(default_tier_llm_model().to_string()),
254 cross_encoder: false,
255 max_memory_mb: 1024,
256 },
257 Self::Autonomous => TierConfig {
258 tier: self,
259 embedding_model: Some(EmbeddingModel::NomicEmbedV15),
260 llm_model: Some(default_tier_llm_model().to_string()),
261 cross_encoder: true,
262 max_memory_mb: 4096,
263 },
264 }
265 }
266
267 /// Automatically select the best tier that fits within `mb` megabytes.
268 #[allow(dead_code)]
269 pub fn from_memory_budget(mb: usize) -> Self {
270 if mb >= 4096 {
271 Self::Autonomous
272 } else if mb >= 1024 {
273 Self::Smart
274 } else if mb >= 256 {
275 Self::Semantic
276 } else {
277 Self::Keyword
278 }
279 }
280}
281
282impl std::fmt::Display for FeatureTier {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 f.write_str(self.as_str())
285 }
286}
287
288// ---------------------------------------------------------------------------
289// Tier configuration
290// ---------------------------------------------------------------------------
291
292/// Runtime configuration derived from a [`FeatureTier`].
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct TierConfig {
295 pub tier: FeatureTier,
296 pub embedding_model: Option<EmbeddingModel>,
297 /// Default backend LLM model tag for this tier, or `None` for tiers that
298 /// use no LLM (keyword / semantic). The value is the provider-agnostic
299 /// compiled default ([`default_tier_llm_model`]); the operator-resolved
300 /// backend/model is carried by [`ResolvedLlm`] via [`AppConfig::resolve_llm`]
301 /// and can be ANY backend. Treated as an on/off gate at the call sites.
302 pub llm_model: Option<String>,
303 pub cross_encoder: bool,
304 pub max_memory_mb: usize,
305}
306
307impl TierConfig {
308 /// Produce a [`Capabilities`] (schema v2) report suitable for JSON
309 /// serialisation. The MCP / HTTP `handle_capabilities_with_conn`
310 /// wrapper overlays live runtime state (recall mode, reranker mode,
311 /// embedder-loaded flag) and live DB counts (active rules, hook
312 /// registrations, pending approvals) before the report goes on the
313 /// wire.
314 ///
315 /// v2 honesty patch (P1, v0.6.3.1): `recall_mode_active` and
316 /// `reranker_active` start at conservative defaults (`disabled` /
317 /// `off`); the wrapper updates them based on the *runtime* embedder
318 /// + reranker handles, not the *configured* tier values.
319 ///
320 /// **#1168 back-compat shim.** Delegates to
321 /// [`Self::capabilities_with_resolved`] with a
322 /// [`ResolvedModels::from_tier_preset`] triple so the
323 /// pre-#1168 wire shape is byte-equal for callers (legacy tests,
324 /// migrate-tool diagnostics) that don't load an operator
325 /// [`AppConfig`]. Production wrappers MUST call
326 /// [`Self::capabilities_with_resolved`] directly with
327 /// [`AppConfig::resolve_models`] output — otherwise
328 /// `memory_capabilities.models.*` drifts from the live LLM /
329 /// embedder / reranker wiring.
330 pub fn capabilities(&self) -> Capabilities {
331 self.capabilities_with_resolved(&ResolvedModels::from_tier_preset(self))
332 }
333
334 /// v0.7.x (issue #1168) — resolver-aware capabilities builder.
335 ///
336 /// Identical to [`Self::capabilities`] except `models.embedding` /
337 /// `models.llm` / `models.cross_encoder` come from the
338 /// operator-resolved `models` triple (built via
339 /// [`AppConfig::resolve_models`]) instead of the compiled tier
340 /// preset. This is the production entry point used by every
341 /// `handle_capabilities_with_conn[_v3]` wrapper post-#1168.
342 ///
343 /// The display logic mirrors the boot banner
344 /// (`src/cli/boot.rs` `BootManifest::build`): Ollama-backend LLM
345 /// emits the bare model id (legacy banner shape); other backends
346 /// emit `backend:model`. Embedder + reranker respect the
347 /// tier-preset disable flag so the keyword tier still reports
348 /// `embedding="none"` even if an operator left a stale
349 /// `[embeddings]` block in their config.
350 #[must_use]
351 pub fn capabilities_with_resolved(&self, models: &ResolvedModels) -> Capabilities {
352 let has_embeddings = self.embedding_model.is_some();
353 let has_llm = self.llm_model.is_some();
354
355 Capabilities {
356 // Capabilities schema v2 — see `Capabilities` doc comment.
357 schema_version: "2".to_string(),
358 tier: self.tier.as_str().to_string(),
359 version: crate::PKG_VERSION.to_string(),
360 features: CapabilityFeatures {
361 keyword_search: true,
362 semantic_search: has_embeddings,
363 hybrid_recall: has_embeddings,
364 query_expansion: has_llm,
365 auto_consolidation: has_llm,
366 auto_tagging: has_llm,
367 contradiction_analysis: has_llm,
368 cross_encoder_reranking: self.cross_encoder,
369 // v0.7.0 recursive-learning (issue #655): the primitive
370 // shipped — Tasks 1-6 landed on
371 // `feat/v0.7.0-recursive-learning`. Flag is enabled and
372 // pinned to the shipping version `v0.7.0`. (Pre-ship,
373 // this was `PlannedFeature::planned("v0.7+")` to keep
374 // the v2 honesty contract honest while the substrate
375 // primitive was on the roadmap.)
376 memory_reflection: PlannedFeature {
377 planned: false,
378 version: "v0.7.0".to_string(),
379 enabled: true,
380 },
381 // Default false — the HTTP/MCP capabilities handler
382 // overwrites this with the live runtime state when it
383 // has access to the embedder handle.
384 embedder_loaded: false,
385 // Conservative defaults; the handler wrapper overlays the
386 // live runtime state (`hybrid` when embedder is loaded,
387 // `keyword_only` when it is not, `degraded` if the load
388 // failed, `disabled` for the keyword tier).
389 recall_mode_active: RecallMode::Disabled,
390 // Conservative default; overwritten when the wrapper has
391 // the actual reranker handle. `off` means no reranker is
392 // configured; `lexical_fallback` means the neural model
393 // failed to materialize; `neural` means the BERT
394 // cross-encoder is loaded.
395 reranker_active: RerankerMode::Off,
396 // v0.7.0 L2-8 — default reflection boost (1.2, +0.05/depth,
397 // cap=3). The MCP/HTTP wrapper overlays the live wrapper
398 // config when a `BatchedReranker` handle is available.
399 reflection_boost: ReflectionBoostReport::default(),
400 },
401 models: build_capability_models(self, models),
402 // v2 dynamic blocks — start at zero-state defaults. The MCP
403 // and HTTP `handle_capabilities` wrappers overwrite these
404 // with live counts when they have a `&Connection` handle.
405 //
406 // Honesty patch (P1): `permissions.mode` is `"advisory"`
407 // until P4 lands the enforcement gate. Was `"ask"`, which
408 // implied an active prompt loop that does not exist.
409 // `rule_summary`, `hooks.by_event`, `approval.subscribers`,
410 // and `approval.default_timeout_seconds` were dropped in v2
411 // because they have no backing implementation.
412 permissions: CapabilityPermissions {
413 // v0.7.0 K3: surface the *active* mode (the one the
414 // gate will actually consult), not a hard-coded string.
415 // Falls through to the K3 default (`advisory`) when
416 // `[permissions].mode` is unset in `config.toml`.
417 mode: active_permissions_mode().as_str().to_string(),
418 active_rules: 0,
419 // v0.7.0 K5: zero-state — no policies known until the
420 // overlay queries the live DB. `Vec::is_empty` means
421 // the field is omitted from the wire entirely (matches
422 // the v0.6.3.1 honesty disclosure that this field was
423 // previously dropped because no per-rule serializer
424 // existed; K5 ships the serializer).
425 rule_summary: Vec::new(),
426 // v0.6.3.1 (P4, G1): chain-walking enforcement landed
427 // in this release. Surface "enforced" so consumers can
428 // distinguish a governed deployment from the historical
429 // "display_only" posture.
430 inheritance: Some("enforced".to_string()),
431 // v0.7.0 K3: per-mode decision counts. Snapshot at
432 // capability-build time so operators can correlate
433 // doctor reports with capability responses.
434 decision_counts: Some(permissions_decision_counts()),
435 },
436 hooks: CapabilityHooks::default(),
437 compaction: CapabilityCompaction::planned(),
438 approval: CapabilityApproval {
439 pending_requests: 0,
440 deferred_audit_dlq_size: 0,
441 },
442 // v0.7.0 #1324 — substrate ships at v0.7.0; flag reads
443 // `planned: false, enabled: false` until an operator wires
444 // the R5 extraction hook and rows land in `memory_transcripts`.
445 // The MCP / HTTP overlay flips `enabled: true` when the live
446 // count is non-zero.
447 transcripts: CapabilityTranscripts::shipped(),
448 hnsw: CapabilityHnsw::default(),
449 // v0.7 J1 — populated by the SAL wrapper at runtime when a
450 // Postgres adapter is active. None at config-construction
451 // time (no SAL handle here); the MCP/HTTP wrapper overlays
452 // the live tag from `PostgresStore::kg_backend()` once
453 // J2 wires the SAL into AppState.
454 kg_backend: None,
455 // L1-1 — always static for v0.7.0; Goal/Plan/Step/Decision
456 // land in L1-6/v0.8.0.
457 memory_kinds: default_memory_kinds(),
458 }
459 }
460}
461
462// ---------------------------------------------------------------------------
463// Capability reporting
464// ---------------------------------------------------------------------------
465
466/// Top-level capabilities report for a running instance.
467///
468/// Schema versions:
469/// - **v1** (legacy, pre-v0.6.3.1): `tier`, `version`, `features`,
470/// `models`. Reachable via `Accept-Capabilities: v1` (HTTP) or the MCP
471/// `accept` argument set to `"v1"`. See [`CapabilitiesV1`].
472/// - **v2** (v0.6.3.1 honesty patch): `schema_version="2"` plus the
473/// `permissions`, `hooks`, `compaction`, `approval`, `transcripts`
474/// blocks. v1 fields preserved at the same top-level paths — old
475/// clients that read v2 by name continue to work for the un-dropped
476/// fields. Default response shape.
477///
478/// **v2 honesty patch (P1, v0.6.3.1):**
479/// - `features.recall_mode_active` and `features.reranker_active` are
480/// *runtime* state, not config-derived flags.
481/// - `features.memory_reflection` is now a `{planned, version, enabled}`
482/// object, not a `bool`.
483/// - `compaction` and `transcripts` carry the same planned-feature
484/// shape so operators can distinguish "disabled but built" from "not
485/// in this build."
486/// - `permissions.mode = "advisory"` until the enforcement gate ships
487/// in P4. Was `"ask"`, which implied an active interactive loop.
488/// - The following fields were **removed** because no backing
489/// implementation exists: `permissions.rule_summary`,
490/// `hooks.by_event`, `approval.subscribers`,
491/// `approval.default_timeout_seconds`.
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct Capabilities {
494 /// Schema-version discriminator. Always `"2"` since v0.6.3.
495 pub schema_version: String,
496 pub tier: String,
497 pub version: String,
498 pub features: CapabilityFeatures,
499 pub models: CapabilityModels,
500
501 /// Active permission/governance rules. Pre-P4 reports the count of
502 /// namespaces that have a `metadata.governance` policy attached to
503 /// their standard memory; the underlying permission system itself
504 /// is P4 work.
505 pub permissions: CapabilityPermissions,
506
507 /// Registered hooks. Pre-v0.7 reports webhook subscriptions as a
508 /// proxy (hook system itself is v0.7 Bucket 0).
509 pub hooks: CapabilityHooks,
510
511 /// Compaction state. v0.8 work — reports `{planned, version,
512 /// enabled}` until the subsystem ships.
513 pub compaction: CapabilityCompaction,
514
515 /// Approval API state. Reports the live count of pending actions
516 /// from the existing `pending_actions` table.
517 pub approval: CapabilityApproval,
518
519 /// Sidechain-transcript state. v0.7 Bucket 1.7 work — reports
520 /// `{planned, version, enabled}` until the subsystem ships.
521 pub transcripts: CapabilityTranscripts,
522
523 /// v0.6.3.1 (P3, G2): HNSW vector-index health. Defaults to a
524 /// quiet zero-state report; the MCP/HTTP capabilities wrapper
525 /// overwrites with live process counters when the index module
526 /// has run an eviction.
527 #[serde(default)]
528 pub hnsw: CapabilityHnsw,
529
530 /// v0.7 J1 — knowledge-graph backend tag. `"age"` when a Postgres
531 /// SAL adapter probed Apache AGE successfully at connect time;
532 /// `"cte"` when the deployment falls back to the recursive-CTE
533 /// path (every SQLite deployment + Postgres without AGE
534 /// installed). `None` when no SAL adapter is wired (the active
535 /// dispatch path through the legacy `crate::db` free functions
536 /// pre-J2). Operators consult this through `ai-memory doctor` and
537 /// `memory_capabilities` to verify which traversal path their
538 /// daemon actually runs. Skipped from the JSON wire when `None`
539 /// so v1 / v2 clients that don't know the field round-trip cleanly.
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub kg_backend: Option<String>,
542
543 /// L1-1 (v0.7.0) — the set of typed memory kinds this binary
544 /// supports. Always `["observation", "reflection"]` for v0.7.0;
545 /// Goal/Plan/Step/Decision land in L1-6/v0.8.0. Callers that want
546 /// to enumerate valid values for a `memory_kind` filter should
547 /// consult this field rather than hardcoding the list.
548 ///
549 /// `#[serde(default)]` keeps older capabilities consumers that
550 /// don't know the field from breaking.
551 #[serde(default = "default_memory_kinds")]
552 pub memory_kinds: Vec<String>,
553}
554
555/// v0.7.0 Gap 4 (#887) — the three thresholds powering the
556/// `ConfidenceTier` enum. `confirmed` and `likely` are inclusive
557/// lower bounds; `ambiguous` is the implicit floor (everything below
558/// `likely`).
559#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
560pub struct ConfidenceTierThresholds {
561 pub confirmed: f64,
562 pub likely: f64,
563 pub ambiguous: f64,
564}
565
566impl Default for ConfidenceTierThresholds {
567 fn default() -> Self {
568 // Mirrors the constants on `crate::models::ConfidenceTier`.
569 // Cannot reference them directly here without inducing a
570 // semantic cycle through `confidence::DEFAULT_HALF_LIFE_DAYS`
571 // already imported in this module; the
572 // `confidence_tier_thresholds_match_model_constants` test
573 // below pins the agreement at build time.
574 Self {
575 confirmed: 0.95,
576 likely: 0.7,
577 ambiguous: 0.0,
578 }
579 }
580}
581
582/// Live recall-mode tag (P1 honesty patch). Reflects the *runtime*
583/// state of the embedder + LLM, not the configured tier.
584///
585/// - `Hybrid` — embedder loaded; semantic + keyword blending active.
586/// - `KeywordOnly` — no embedder loaded; FTS5 only.
587/// - `Degraded` — embedder configured but `Embedder::load()` failed
588/// (offline runner, read-only fs, missing HF token, etc.).
589/// - `Disabled` — keyword-tier daemon, semantic recall not configured.
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
591#[serde(rename_all = "snake_case")]
592pub enum RecallMode {
593 Hybrid,
594 KeywordOnly,
595 Degraded,
596 Disabled,
597}
598
599/// Live reranker-mode tag (P1 honesty patch). Reflects the *runtime*
600/// `CrossEncoder` enum variant, not the configured `cross_encoder` flag.
601///
602/// - `Neural` — `CrossEncoder::Neural` loaded successfully.
603/// - `LexicalFallback` — `cross_encoder` was requested but neural model
604/// download or load failed; running on the lexical scorer.
605/// - `Off` — no reranker handle in the daemon (non-autonomous tier).
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
607#[serde(rename_all = "snake_case")]
608pub enum RerankerMode {
609 Neural,
610 LexicalFallback,
611 Off,
612}
613
614/// Generic "planned but not implemented" marker used by v2 capability
615/// fields whose underlying subsystem is on the roadmap but not in this
616/// build. Operators reading the JSON can distinguish "disabled but
617/// available" from "not in this build" by inspecting `planned`.
618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
619pub struct PlannedFeature {
620 /// `true` when the feature exists only on the roadmap.
621 pub planned: bool,
622 /// Earliest release that is expected to ship the feature, e.g.
623 /// `"v0.7+"` or `"v0.8+"`. Free-form string; clients should treat
624 /// it as advisory.
625 pub version: String,
626 /// `true` only when the feature is built **and** turned on in this
627 /// daemon. Always `false` when `planned == true`.
628 pub enabled: bool,
629}
630
631impl PlannedFeature {
632 /// A planned-not-yet-shipped feature. `enabled = false`.
633 #[must_use]
634 pub fn planned(version: &str) -> Self {
635 Self {
636 planned: true,
637 version: version.to_string(),
638 enabled: false,
639 }
640 }
641}
642
643/// Boolean feature flags exposed in the capabilities report.
644#[allow(clippy::struct_excessive_bools)]
645#[derive(Debug, Clone, Serialize, Deserialize)]
646pub struct CapabilityFeatures {
647 pub keyword_search: bool,
648 pub semantic_search: bool,
649 pub hybrid_recall: bool,
650 pub query_expansion: bool,
651 pub auto_consolidation: bool,
652 pub auto_tagging: bool,
653 pub contradiction_analysis: bool,
654 pub cross_encoder_reranking: bool,
655 /// Memory-reflection (v0.7.0): planned-feature object. Was a
656 /// `bool` before the v0.6.3.1 P1 honesty patch; an object now so
657 /// operators can tell "feature exists but disabled" apart from
658 /// "feature not in this build".
659 ///
660 /// **v0.7.0 recursive-learning ship (issue #655).** The flag is
661 /// `{ planned: false, version: "v0.7.0", enabled: true }` because
662 /// the underlying primitive landed across Tasks 1-6 on
663 /// `feat/v0.7.0-recursive-learning`:
664 ///
665 /// - **Column** (Task 1/8, commit `f5d8a9e`) —
666 /// `memories.reflection_depth INTEGER NOT NULL DEFAULT 0`,
667 /// first added in the recursive-learning schema bump (column
668 /// inventory lives in `docs/MIGRATION_v0.7.md`; the current
669 /// `CURRENT_SCHEMA_VERSION` is 53 in lockstep on both sqlite
670 /// and postgres ladders as of v0.7.0 — v48 added
671 /// `federation_push_dlq` (#933), v49 added 14 nullable
672 /// `archived_memories` columns (#1025), v50 extended
673 /// `agent_quotas` PK with `namespace` (#1156), v51 added
674 /// `federation_nonce_cache` (#1255 / PR #1296), v52 added
675 /// `transcript_line_dedup` (#1389 L4 / RFC-0001), v53 scoped
676 /// the `memories_au` FTS5 sync trigger to (title, content, tags)
677 /// only (R5.F5.2 / #1418)).
678 /// `Memory::reflection_depth: i32` with `#[serde(default)]` for
679 /// wire-compat with pre-v0.7.0 federation peers.
680 /// - **Governance field** (Task 2/8, commit `630a6db`) —
681 /// `GovernancePolicy.max_reflection_depth: Option<u32>` (per
682 /// namespace, JSON metadata, no schema bump). Accessor
683 /// `effective_max_reflection_depth() -> u32` returns the compiled
684 /// default `3` when unset; `Some(0)` is the documented
685 /// kill-switch.
686 /// - **Relation** (Task 3/8, commit `b51a3f3`) — `reflects_on`
687 /// joins the canonical `VALID_RELATIONS` set; directionality
688 /// matches `derived_from` (reflection is `source_id`, original
689 /// is `target_id`); `db::find_paths` walks it without further
690 /// work.
691 /// - **MCP tool** (Task 4/8, commit `3dc76f3`) — `memory_reflect`
692 /// (`Family::Power`, tool count 51 → 52). Atomic insert of a
693 /// reflection memory + N `reflects_on` link writes inside a
694 /// single `BEGIN IMMEDIATE` / `COMMIT` transaction. Postgres
695 /// parity via inherent `PostgresStore::reflect`.
696 /// - **Error variant** (Task 4/8) — `MemoryError::ReflectionDepthExceeded
697 /// { attempted: u32, cap: u32, namespace: String }` →
698 /// HTTP `409 CONFLICT`, code `REFLECTION_DEPTH_EXCEEDED`.
699 /// - **Hook events** (Task 6/8, commit `fbf093c`) —
700 /// `HookEvent::PreReflect` (decision-class, `EventClass::Write`,
701 /// 5s deadline, fires before the depth-cap check, `Deny`
702 /// vetoes via `ReflectError::HookVeto`) +
703 /// `HookEvent::PostReflect` (notify-class, `EventClass::Write`,
704 /// 5s deadline, fires after `COMMIT`). Pipeline event count
705 /// 21 → 23.
706 /// - **Audit chain** (Task 5/8, commit `c61a05b`) — every
707 /// depth-cap refusal appends a `reflection.depth_exceeded` row
708 /// to the append-only `signed_events` audit table under a
709 /// canonical-CBOR payload + SHA-256 `payload_hash` +
710 /// `attest_level = "unsigned"`. Content body is deliberately
711 /// omitted (PII guarantee); hook vetoes are NOT audited by this
712 /// row (caller-policy refusals carry their own provenance).
713 ///
714 /// The v1 wire-shape projection collapses this object back to a
715 /// single `bool` (via `Capabilities::to_v1`), so pre-v0.6.3.1
716 /// clients that pinned the v1 schema continue to see the same
717 /// boolean field at the same path (and now read `true`).
718 pub memory_reflection: PlannedFeature,
719 /// v0.6.2 (S18): runtime-observed embedder state. `semantic_search`
720 /// above reflects *configured* capability (derived from the tier's
721 /// `embedding_model` setting). `embedder_loaded` reflects *actual*
722 /// state after `Embedder::load()` attempted to materialize the
723 /// `HuggingFace` model on startup. When an operator configures the
724 /// `semantic` tier but the model download or mmap fails (offline
725 /// runner, read-only fs, missing tokens), `semantic_search=true`
726 /// would mislead. This flag exposes the truth so setup scripts can
727 /// assert the daemon is actually ready for semantic recall before
728 /// dispatching scenarios. Default false; populated by
729 /// `handle_capabilities` when the HTTP/MCP wrapper hands in the
730 /// live embedder handle.
731 #[serde(default)]
732 pub embedder_loaded: bool,
733 /// v0.6.3.1 (P1 honesty patch): runtime recall-mode tag. Reflects
734 /// the live embedder + LLM availability, not the configured tier.
735 /// See [`RecallMode`].
736 #[serde(default = "default_recall_mode")]
737 pub recall_mode_active: RecallMode,
738 /// v0.6.3.1 (P1 honesty patch): runtime reranker-mode tag.
739 /// Reflects the live `CrossEncoder` variant. See [`RerankerMode`].
740 #[serde(default = "default_reranker_mode")]
741 pub reranker_active: RerankerMode,
742 /// v0.7.0 L2-8 — reflection-aware reranker boost configuration.
743 /// `boost = 1.0` means the boost is disabled and the reranker
744 /// reproduces its pre-L2-8 behavior. Default (`1.2`) is the value
745 /// the daemon ships with; operators can inspect this to verify
746 /// the live boost matches their configured policy. Skipped from
747 /// the wire when serialising a pre-L2-8 default so older
748 /// capabilities consumers round-trip cleanly.
749 #[serde(default = "default_reflection_boost")]
750 pub reflection_boost: ReflectionBoostReport,
751}
752
753/// v0.7.0 L2-8 — per-field report of the reflection-aware reranker
754/// boost surfaced through `memory_capabilities`. Mirrors
755/// [`crate::reranker::ReflectionBoostConfig`] but expressed in
756/// capability-report shape (serde-friendly, schema-tagged).
757#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
758pub struct ReflectionBoostReport {
759 /// Multiplicative boost applied to reflection-kind memories.
760 /// `1.0` disables; default `1.2`.
761 pub boost: f32,
762 /// Per-depth additional multiplier increment. Default `0.05`.
763 pub per_depth_increment: f32,
764 /// Depth cap for the per-depth multiplier. Default `3`.
765 pub max_depth_cap: u32,
766}
767
768impl Default for ReflectionBoostReport {
769 fn default() -> Self {
770 Self {
771 boost: crate::reranker::DEFAULT_REFLECTION_BOOST,
772 per_depth_increment: crate::reranker::DEFAULT_REFLECTION_PER_DEPTH_INCREMENT,
773 max_depth_cap: crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP,
774 }
775 }
776}
777
778impl From<&crate::reranker::ReflectionBoostConfig> for ReflectionBoostReport {
779 fn from(cfg: &crate::reranker::ReflectionBoostConfig) -> Self {
780 Self {
781 boost: cfg.boost,
782 per_depth_increment: cfg.per_depth_increment,
783 max_depth_cap: cfg.max_depth_cap,
784 }
785 }
786}
787
788fn default_reflection_boost() -> ReflectionBoostReport {
789 ReflectionBoostReport::default()
790}
791
792/// L1-1 default: the two typed memory kinds shipping in v0.7.0.
793fn default_memory_kinds() -> Vec<String> {
794 vec!["observation".to_string(), "reflection".to_string()]
795}
796
797fn default_recall_mode() -> RecallMode {
798 RecallMode::Disabled
799}
800
801fn default_reranker_mode() -> RerankerMode {
802 RerankerMode::Off
803}
804
805/// Model identifiers exposed in the capabilities report.
806#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct CapabilityModels {
808 pub embedding: String,
809 pub embedding_dim: usize,
810 pub llm: String,
811 pub cross_encoder: String,
812}
813
814/// v0.7.x (issue #1168) — build the `models.*` block of the
815/// capabilities report from the resolver-aware
816/// [`ResolvedModels`] triple, NOT the compiled tier preset.
817///
818/// Display logic mirrors `src/cli/boot.rs` `BootManifest::build`
819/// (v0.7.x #1146) so the boot banner and `memory_capabilities`
820/// agree byte-for-byte on what backend / model the daemon is
821/// wired to:
822///
823/// - `llm` — `"none"` when no LLM is configured; bare `model` for
824/// Ollama backends (legacy banner shape); `backend:model` for
825/// every OpenAI-compatible vendor (xAI, OpenAI, Anthropic,
826/// Gemini, DeepSeek, Kimi, Qwen, Mistral, Groq, Together,
827/// Cerebras, OpenRouter, Fireworks, LMStudio, vLLM, llama.cpp).
828/// - `embedding` — `"none"` when the tier preset disables the
829/// embedder (`keyword` tier); otherwise the resolver's canonical
830/// model string.
831/// - `embedding_dim` — v0.7.x (issue #1169): sourced from
832/// [`ResolvedEmbeddings::embedding_dim`] when the resolver
833/// recognised the operator-picked model id (via the
834/// [`KNOWN_EMBEDDING_DIMS`] lookup); falls back to the tier preset
835/// ([`EmbeddingModel::dim`]) only when the operator's model is not
836/// in the table. Pre-#1169 this field was sourced ONLY from the
837/// tier preset, which silently drifted the moment an operator set
838/// `[embeddings].model` to anything outside the 2-family
839/// [`EmbeddingModel`] enum.
840/// - `cross_encoder` — `"none"` when neither the resolver nor the
841/// tier preset enables the cross-encoder; otherwise the
842/// resolver's model string.
843#[must_use]
844pub fn build_capability_models(tier: &TierConfig, models: &ResolvedModels) -> CapabilityModels {
845 let llm = if models.llm.model.is_empty() {
846 "none".to_string()
847 } else if models.llm.is_ollama_native() {
848 models.llm.model.clone()
849 } else {
850 models.llm.display_label()
851 };
852
853 let embedding = if tier.embedding_model.is_none() {
854 // Tier-preset disabled — keep the historical "none" sentinel
855 // even if a stale `[embeddings]` block remains in config.
856 "none".to_string()
857 } else {
858 models.embeddings.model.clone()
859 };
860
861 // v0.7.x (#1169) — resolver-side dim wins when known; tier preset
862 // is the back-compat fallback for unrecognised model ids and the
863 // tier-disabled-embedder posture (where the field stays 0 to match
864 // pre-#1169 semantics).
865 let embedding_dim = if tier.embedding_model.is_none() {
866 0
867 } else {
868 models.embeddings.embedding_dim.map_or_else(
869 || tier.embedding_model.map_or(0, EmbeddingModel::dim),
870 |d| d as usize,
871 )
872 };
873
874 let cross_encoder = if models.reranker.enabled || tier.cross_encoder {
875 models.reranker.model.clone()
876 } else {
877 "none".to_string()
878 };
879
880 CapabilityModels {
881 embedding,
882 embedding_dim,
883 llm,
884 cross_encoder,
885 }
886}
887
888/// Permissions block (capabilities schema v2). Pre-P4 reports a live
889/// count of namespace standards carrying a `metadata.governance` policy;
890/// the full enforcement gate lands in P4. The honesty patch (P1)
891/// renames the mode from `"ask"` (which implied an interactive prompt
892/// loop) to `"advisory"` (governance metadata is recorded but not
893/// enforced).
894#[derive(Debug, Clone, Serialize, Deserialize, Default)]
895pub struct CapabilityPermissions {
896 /// Enforcement mode. `"advisory"` until P4 ships the gate.
897 pub mode: String,
898 /// Number of namespace standards whose `metadata.governance` is
899 /// non-null. Counts policies, not memories.
900 pub active_rules: usize,
901 /// v0.7.0 K5: ordered list of one-line summaries — one entry per
902 /// active governance policy, sorted lexicographically by namespace.
903 /// Each entry names the namespace plus the policy's `write`,
904 /// `promote`, `delete`, `approver`, and `inherit` values so an
905 /// operator (or LLM) can see the live ruleset at a glance without
906 /// fanning out per-namespace `memory_namespace_get_standard` calls.
907 ///
908 /// **Wire shape.** `skip_serializing_if = "Vec::is_empty"` keeps the
909 /// field absent from v2 responses (which historically had no per-rule
910 /// serializer — the v0.6.3.1 honesty patch dropped the field from
911 /// the v2 wire entirely) when no policies are configured. v3 callers
912 /// see the field on every response with policies, matching the K5
913 /// spec contract that v3 brings the field back with a backing
914 /// implementation.
915 ///
916 /// Closes the v0.6.3.1 honest-Capabilities-v2 disclosure that this
917 /// field was a placeholder — the K5 increment ships the per-rule
918 /// serializer that was previously missing.
919 #[serde(default, skip_serializing_if = "Vec::is_empty")]
920 pub rule_summary: Vec<String>,
921 /// v0.6.3.1 (P4, audit G1): governance-inheritance posture.
922 /// `"enforced"` = `resolve_governance_policy` walks the namespace
923 /// chain leaf-first and returns the most-specific policy (with
924 /// `inherit: false` short-circuiting). Pre-v0.6.3.1 was
925 /// `"display_only"` — the UI surfaced the chain but the gate
926 /// consulted only the leaf, leaving children of governed parents
927 /// completely ungoverned. The field is `Option<String>` so older
928 /// capabilities responses (without the field) round-trip cleanly
929 /// via `#[serde(default)]`.
930 #[serde(default)]
931 pub inheritance: Option<String>,
932 /// v0.7.0 K3: per-mode decision counts since process start. Lets
933 /// operators verify the gate is actually being consulted and spot
934 /// drift between advertised policy and enforced policy. `None` on
935 /// older responses (`#[serde(default)]` round-trips cleanly).
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub decision_counts: Option<PermissionsDecisionCounts>,
938}
939
940/// Hook-pipeline block (capabilities schema v2). Pre-v0.7 reports webhook
941/// subscriptions as the closest analogue. The full hook pipeline lands in
942/// v0.7 Bucket 0 (arch-enhancement-spec §2).
943#[derive(Debug, Clone, Serialize, Deserialize)]
944pub struct CapabilityHooks {
945 /// Number of registered hook subscribers (proxy: webhook subscriptions).
946 pub registered_count: usize,
947 // P1 honesty patch: `by_event` was always an empty map — no event
948 // registry exists. Dropped from the v2 wire schema.
949 /// v0.6.3.1 P5 (G9): canonical list of webhook event types the
950 /// daemon emits. Integrators pin the `subscribe(event_types: …)`
951 /// filter against these strings. Always populated so downstream
952 /// callers do not have to handle a missing field.
953 #[serde(default = "default_webhook_events")]
954 pub webhook_events: Vec<String>,
955 /// v0.7.0 L1-7: total number of distinct `HookEvent` variants the
956 /// pipeline supports. Populated from the compile-time constant
957 /// [`HOOK_EVENTS_COUNT`] so operators and integrations can verify
958 /// they are running against the expected pipeline version without
959 /// enumerating the enum.
960 ///
961 /// History: G2 shipped 20; G10 added the 21st; Task 6/8 added
962 /// the 22nd + 23rd; L1-7 adds the 24th + 25th → total **25**.
963 #[serde(default = "default_hook_events_count")]
964 pub hook_events_count: usize,
965 /// v0.7-polish SEC-15 / COR-11 (issue #780): mirror of the
966 /// process-wide
967 /// `crate::metrics::auto_export_spawn_failed_total` counter.
968 /// Non-zero means at least one `post_reflect.auto_export` detached
969 /// worker panicked or returned `Err` since process start — the
970 /// reflection is committed in the DB but its on-disk markdown/json
971 /// artefact did NOT land. Operators alert on a non-zero value
972 /// without scraping `/metrics` directly.
973 ///
974 /// `skip_serializing_if = is_zero_u64` keeps healthy daemons'
975 /// capabilities responses byte-identical to pre-#780 — only
976 /// daemons that have actually hit the failure path see the field
977 /// on the wire. The MCP/HTTP capabilities builder overlays the
978 /// live value at response time.
979 #[serde(default, skip_serializing_if = "is_zero_u64")]
980 pub auto_export_spawn_failed_total: u64,
981}
982
983/// Compile-time count of `HookEvent` variants. Updated here when new
984/// variants land; the corresponding enum exhaustiveness check in
985/// `src/hooks/timeouts.rs` enforces the count at test time.
986pub const HOOK_EVENTS_COUNT: usize = 25;
987
988fn default_hook_events_count() -> usize {
989 HOOK_EVENTS_COUNT
990}
991
992impl Default for CapabilityHooks {
993 fn default() -> Self {
994 Self {
995 registered_count: 0,
996 webhook_events: default_webhook_events(),
997 hook_events_count: HOOK_EVENTS_COUNT,
998 auto_export_spawn_failed_total: 0,
999 }
1000 }
1001}
1002
1003/// Default webhook events list — kept in sync with
1004/// `crate::subscriptions::WEBHOOK_EVENT_TYPES`. The constant lives in
1005/// `subscriptions.rs` (the surface that uses it at runtime); this
1006/// helper exists so `serde(default = …)` and `CapabilityHooks::default`
1007/// can fill the field without a cross-module dep on `subscriptions`.
1008///
1009/// v0.7.0 K4 — `approval_requested` joined the canonical list. The
1010/// `webhook_events` capability surface is the integration contract
1011/// for K10's Approval API HTTP+SSE handler; surfacing the event type
1012/// here closes the v0.6.3.1 honest-disclosure that the
1013/// `approval.subscribers` field was advertised but unwired.
1014fn default_webhook_events() -> Vec<String> {
1015 // v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): the
1016 // three entries that ARE MCP tool names route through the
1017 // canonical `tool_names` consts; the remaining four are
1018 // subscription-event types (different namespace) and stay raw.
1019 use crate::mcp::registry::tool_names as tn;
1020 vec![
1021 tn::MEMORY_STORE.to_string(),
1022 tn::MEMORY_PROMOTE.to_string(),
1023 tn::MEMORY_DELETE.to_string(),
1024 crate::subscriptions::webhook_events::MEMORY_LINK_CREATED.to_string(),
1025 crate::subscriptions::webhook_events::MEMORY_LINK_INVALIDATED.to_string(),
1026 crate::subscriptions::webhook_events::MEMORY_CONSOLIDATED.to_string(),
1027 crate::subscriptions::webhook_events::APPROVAL_REQUESTED.to_string(),
1028 ]
1029}
1030
1031/// Compaction block (capabilities schema v2). v0.8 Pillar 2.5 work —
1032/// reports `{planned, version, enabled}` plus optional run stats. The
1033/// honesty patch (P1) replaced the bare `enabled: false` with the
1034/// planned-feature shape so operators can distinguish "feature exists
1035/// but disabled" from "feature not in this build".
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1037pub struct CapabilityCompaction {
1038 /// Planned-feature marker. `planned = true` while compaction lives
1039 /// only on the roadmap. When the subsystem ships the daemon will
1040 /// flip `planned = false` and `enabled` will reflect runtime state.
1041 #[serde(flatten)]
1042 pub status: PlannedFeature,
1043 /// Once shipped: scheduled compaction interval in minutes.
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1045 pub interval_minutes: Option<u64>,
1046 /// Once shipped: timestamp of the most recent compaction run.
1047 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub last_run_at: Option<String>,
1049 /// Once shipped: arbitrary JSON describing the most recent run.
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub last_run_stats: Option<serde_json::Value>,
1052}
1053
1054impl CapabilityCompaction {
1055 /// Pre-v0.8 zero-state: planned, not enabled.
1056 #[must_use]
1057 pub fn planned() -> Self {
1058 Self {
1059 status: PlannedFeature::planned("v0.8+"),
1060 interval_minutes: None,
1061 last_run_at: None,
1062 last_run_stats: None,
1063 }
1064 }
1065}
1066
1067impl Default for CapabilityCompaction {
1068 fn default() -> Self {
1069 Self::planned()
1070 }
1071}
1072
1073/// Approval-API block (capabilities schema v2). `pending_requests`
1074/// counts the existing `pending_actions` table (live signal).
1075#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1076pub struct CapabilityApproval {
1077 /// Live count of `pending_actions` with status='pending'.
1078 pub pending_requests: usize,
1079 // P1 honesty patch: `subscribers` (no subscription API exists) and
1080 // `default_timeout_seconds` (no sweeper enforces timeouts) dropped
1081 // from the v2 wire schema.
1082 /// v0.7.0 Cluster-C SEC-3 (issue #767) — live count of rows in
1083 /// `signed_events_dlq` (the deferred-audit drainer's dead-letter
1084 /// queue). Non-zero means at least one storage-hook
1085 /// `governance.refusal` event failed to chain-log into
1086 /// `signed_events` and landed in the DLQ for operator replay.
1087 /// Default-omitted from the wire when zero so existing dashboards
1088 /// see no churn on healthy daemons.
1089 #[serde(default, skip_serializing_if = "is_zero_u64")]
1090 pub deferred_audit_dlq_size: u64,
1091}
1092
1093/// Sidechain-transcript block (capabilities schema v2). v0.7 Bucket 1.7
1094/// work — reports `{planned, version, enabled}` until the subsystem
1095/// ships. The honesty patch (P1) replaced the bare `enabled: false`
1096/// with the planned-feature shape.
1097#[derive(Debug, Clone, Serialize, Deserialize)]
1098pub struct CapabilityTranscripts {
1099 /// Planned-feature marker. `planned = true` while sidechain
1100 /// transcripts live only on the roadmap.
1101 #[serde(flatten)]
1102 pub status: PlannedFeature,
1103 /// Once shipped: number of stored transcripts.
1104 #[serde(default, skip_serializing_if = "is_zero_usize")]
1105 pub total_count: usize,
1106 /// Once shipped: total transcript storage in megabytes.
1107 #[serde(default, skip_serializing_if = "is_zero_u64")]
1108 pub total_size_mb: u64,
1109}
1110
1111impl CapabilityTranscripts {
1112 /// Pre-v0.7 zero-state: planned, not enabled. Retained for the
1113 /// pre-build capability surface used by the bootstrap config; the
1114 /// MCP / HTTP overlay flips this to [`Self::shipped`] before the
1115 /// report goes on the wire at v0.7.0+.
1116 #[must_use]
1117 pub fn planned() -> Self {
1118 Self {
1119 status: PlannedFeature::planned("v0.7+"),
1120 total_count: 0,
1121 total_size_mb: 0,
1122 }
1123 }
1124
1125 /// v0.7.0 #1324 — the substrate ships at v0.7.0: zstd-3 BLOB
1126 /// store, `memory_transcripts` table, `memory_transcript_links`
1127 /// join, `replay_transcript_union` walk, the `memory_replay` MCP
1128 /// tool, and the per-namespace lifecycle sweep are all on disk.
1129 /// Operators flip `enabled: true` by wiring the R5 reference
1130 /// `pre_store` hook (`tools/transcript-extractor/`) — the
1131 /// substrate cannot link transcripts without an operator-driven
1132 /// extraction path, so this constructor reflects "shipped but
1133 /// awaiting per-namespace opt-in." The live MCP / HTTP overlay
1134 /// can additionally flip `enabled` when it observes a non-zero
1135 /// transcript count (operator opt-in is observed indirectly via
1136 /// presence of rows).
1137 ///
1138 /// Returning `planned: false` here closes the v0.7.0 honesty drift
1139 /// — the pre-#1324 surface advertised `planned: true` even after
1140 /// the substrate landed, which confused operators reading the
1141 /// capabilities surface as a feature-availability oracle.
1142 #[must_use]
1143 pub fn shipped() -> Self {
1144 Self {
1145 status: PlannedFeature {
1146 planned: false,
1147 version: crate::PKG_VERSION.to_string(),
1148 enabled: false,
1149 },
1150 total_count: 0,
1151 total_size_mb: 0,
1152 }
1153 }
1154}
1155
1156impl Default for CapabilityTranscripts {
1157 fn default() -> Self {
1158 Self::planned()
1159 }
1160}
1161
1162#[allow(clippy::trivially_copy_pass_by_ref)]
1163fn is_zero_usize(n: &usize) -> bool {
1164 *n == 0
1165}
1166
1167#[allow(clippy::trivially_copy_pass_by_ref)]
1168fn is_zero_u64(n: &u64) -> bool {
1169 *n == 0
1170}
1171
1172/// HNSW vector-index health (capabilities schema v2, v0.6.3.1 P3).
1173///
1174/// Closes the G2 audit gap by surfacing both the cumulative oldest-eviction
1175/// count and a rolling-window flag so operators can distinguish "this
1176/// process has hit the cap once, long ago" from "we are currently
1177/// sustained at the cap and shedding embeddings now". Both numbers are
1178/// process-local — the index itself resets on restart so persistence
1179/// would be misleading.
1180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1181pub struct CapabilityHnsw {
1182 /// Cumulative count of vectors evicted by the `MAX_ENTRIES`-cap path
1183 /// since this process started.
1184 pub evictions_total: u64,
1185 /// True when at least one eviction has occurred in the last 60 s.
1186 /// Lets dashboards alert on *active* pressure rather than only the
1187 /// historical counter.
1188 pub evicted_recently: bool,
1189}
1190
1191// ---------------------------------------------------------------------------
1192// Capabilities v3 L3-5 — recursive-learning / skills / forensic / governance
1193// blocks. v3-only (additive over v2). Every field is hand-mapped to a
1194// concrete implementation that landed in the v0.7.0 grand-slam L1+L2 waves
1195// so an external auditor can trace a claim back to a source-code line.
1196// ---------------------------------------------------------------------------
1197
1198/// v0.7.0 L3-5 — substrate-native reflection capability surface.
1199///
1200/// Every field MUST map to a real implementation. Audit anchors:
1201///
1202/// - `implemented`: [`crate::storage::reflect::reflect`] +
1203/// [`crate::mcp::tools::memory_reflect`] (issue #655 Task 4/8,
1204/// commit `3dc76f3`).
1205/// - `depth_bounded`: depth-cap check in [`crate::storage::reflect`]
1206/// step 5; [`crate::errors::MemoryError::ReflectionDepthExceeded`]
1207/// surfaces refusal with `attempted` + `cap` + `namespace`.
1208/// - `max_default`: compiled-in default returned by
1209/// [`crate::models::namespace::GovernancePolicy::effective_max_reflection_depth`]
1210/// (currently **3**) when the namespace's
1211/// `metadata.governance.max_reflection_depth` is unset.
1212/// - `attestation`: every reflection writes a `signed_events` row via
1213/// [`crate::signed_events::append_signed_event`]; the project uses
1214/// Ed25519 (see [`crate::identity::sign`] H2 + H4 link-signing
1215/// plus the operator-signed governance rules in
1216/// [`crate::governance::rules_store`]).
1217/// - `curator_mode`: implemented in
1218/// [`crate::curator::reflection_pass`] and the
1219/// `ai-memory curator --reflection-pass` CLI verb in
1220/// [`crate::cli::curator`].
1221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1222pub struct CapabilityReflection {
1223 /// `true` whenever the reflection primitive is wired (memory_reflect MCP
1224 /// tool present + `storage::reflect::reflect` callable). False is reserved
1225 /// for a build that compiled the field out.
1226 pub implemented: bool,
1227 /// `true` when reflections are subject to a depth cap that refuses
1228 /// further reflection past the configured maximum.
1229 pub depth_bounded: bool,
1230 /// Compiled-in default cap returned when no namespace policy is set.
1231 /// Tracks [`crate::models::namespace::GovernancePolicy::effective_max_reflection_depth`].
1232 pub max_default: u32,
1233 /// Signature algorithm used by the substrate for attested events
1234 /// touching reflections (link signatures + `signed_events` rows).
1235 pub attestation: String,
1236 /// `"implemented"` when the curator reflection pass is wired
1237 /// (`curator::reflection_pass` + `ai-memory curator` CLI). Stays a
1238 /// string (not a bool) so future increments can grow new values like
1239 /// `"scheduled"` without a wire-shape break.
1240 pub curator_mode: String,
1241}
1242
1243impl CapabilityReflection {
1244 /// Build the L3-5 reflection capability from real values pinned at
1245 /// compile time so the wire shape reflects what this binary actually
1246 /// ships. Constants from [`crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP`]
1247 /// and the curator module are consulted directly — no magic strings.
1248 #[must_use]
1249 pub fn current() -> Self {
1250 Self {
1251 implemented: true,
1252 depth_bounded: true,
1253 max_default: crate::reranker::DEFAULT_REFLECTION_MAX_DEPTH_CAP,
1254 attestation: "Ed25519".to_string(),
1255 // #1672 — the curator reflection pass (`curator --reflect`) is
1256 // `#[cfg(feature = "sal")]`-gated; on the default sqlite-bundled
1257 // build it hard-bails (`cli/curator.rs:548-560`), so reporting
1258 // `"implemented"` over-reports the surface. Gate the honest value.
1259 curator_mode: if cfg!(feature = "sal") {
1260 IMPLEMENTED.to_string()
1261 } else {
1262 CURATOR_MODE_REQUIRES_SAL.to_string()
1263 },
1264 }
1265 }
1266}
1267
1268fn default_capability_reflection() -> CapabilityReflection {
1269 CapabilityReflection::current()
1270}
1271
1272/// v0.7.0 L3-5 — Agent-Skills capability surface.
1273///
1274/// Every field MUST map to a real implementation:
1275///
1276/// - `implemented`: 7 MCP tools wired in
1277/// [`crate::mcp::registry`] + handlers in
1278/// [`crate::mcp::tools::skill_*`].
1279/// - `standard`: the parser in [`crate::parsing::skill_md`] validates
1280/// names + frontmatter against the agentskills.io §3.1/§3.2 spec.
1281/// - `tools`: list mirrors the registered handler names verbatim;
1282/// regression test [`SKILL_TOOL_NAMES`] verifies the slice matches
1283/// the live MCP dispatcher.
1284/// - `round_trip`: `memory_skill_register` → `memory_skill_export` →
1285/// re-register produces the IDENTICAL SHA-256 digest (see
1286/// `tests/skill_test.rs`, the round-trip pin).
1287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1288pub struct CapabilitySkills {
1289 /// `true` whenever the skill registration + lookup substrate is
1290 /// wired. False is reserved for a build that compiled the family out.
1291 pub implemented: bool,
1292 /// External spec the parser targets. `"agentskills.io"` is the
1293 /// canonical name documented in the L1-5 spec.
1294 pub standard: String,
1295 /// Canonical list of registered skill tools. Order matches the MCP
1296 /// dispatch order so an LLM that pins the order doesn't drift.
1297 pub tools: Vec<String>,
1298 /// `"verified"` when register → export → re-register is exercised in
1299 /// the test suite and the digests match.
1300 pub round_trip: String,
1301}
1302
1303/// Canonical skill tool names as registered in
1304/// [`crate::mcp::registry`]. Pinned here (not derived from the registry)
1305/// so the capability surface remains a stable, declarative contract;
1306/// the regression test
1307/// `cap_v3_l3_5_skill_tools_match_registered_mcp_dispatch` ensures the
1308/// two stay in sync.
1309// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — each
1310// entry routes through the canonical `tool_names` const so this
1311// capability surface cannot drift from the dispatch table in name
1312// spelling. The `cap_v3_l3_5_skill_tools_match_registered_mcp_dispatch`
1313// regression test continues to enforce membership equality between
1314// this slice and the registered set.
1315pub const SKILL_TOOL_NAMES: &[&str] = &[
1316 crate::mcp::registry::tool_names::MEMORY_SKILL_REGISTER,
1317 crate::mcp::registry::tool_names::MEMORY_SKILL_LIST,
1318 crate::mcp::registry::tool_names::MEMORY_SKILL_GET,
1319 crate::mcp::registry::tool_names::MEMORY_SKILL_RESOURCE,
1320 crate::mcp::registry::tool_names::MEMORY_SKILL_EXPORT,
1321 crate::mcp::registry::tool_names::MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
1322 crate::mcp::registry::tool_names::MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
1323];
1324
1325impl CapabilitySkills {
1326 /// Build the L3-5 skills capability from real, code-anchored values.
1327 #[must_use]
1328 pub fn current() -> Self {
1329 Self {
1330 implemented: true,
1331 standard: "agentskills.io".to_string(),
1332 tools: SKILL_TOOL_NAMES.iter().map(|s| (*s).to_string()).collect(),
1333 round_trip: "verified".to_string(),
1334 }
1335 }
1336}
1337
1338fn default_capability_skills() -> CapabilitySkills {
1339 CapabilitySkills::current()
1340}
1341
1342/// Capability-matrix value string — a surface is reported as
1343/// `"implemented"` once its engine/hook/wrapper code is live. One named
1344/// const so the 18 matrix cells share a single spelling (pm-v3.1
1345/// hardcoded-literal gate, #1558 wave 4).
1346const IMPLEMENTED: &str = "implemented";
1347
1348/// #1672 — honest `curator_mode` value on non-`sal` builds, where the curator
1349/// reflection pass (`curator --reflect`) is compiled to a hard-bail companion.
1350const CURATOR_MODE_REQUIRES_SAL: &str = "requires_sal_feature";
1351
1352/// v0.7.0 L3-5 — forensic-evidence capability surface.
1353///
1354/// Each label names a CLI / function pair that **exists** in this binary:
1355///
1356/// - `verify_reflection_chain`: `ai-memory verify-reflection-chain` —
1357/// driver lives in [`crate::cli::verify`].
1358/// - `export_forensic_bundle`: `ai-memory export-forensic-bundle` —
1359/// builder lives in [`crate::forensic::bundle::build`].
1360/// - `verify_forensic_bundle`: `ai-memory verify-forensic-bundle` —
1361/// verifier lives in [`crate::forensic::bundle::verify`].
1362///
1363/// All three are `"implemented"` strings (not bools) so future
1364/// increments can promote a value to `"attested"` or `"scheduled"`
1365/// without a wire-shape break.
1366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1367pub struct CapabilityForensic {
1368 pub verify_reflection_chain: String,
1369 pub export_forensic_bundle: String,
1370 pub verify_forensic_bundle: String,
1371}
1372
1373impl CapabilityForensic {
1374 /// Build the L3-5 forensic capability — all three driver paths are
1375 /// wired in this build.
1376 #[must_use]
1377 pub fn current() -> Self {
1378 Self {
1379 verify_reflection_chain: IMPLEMENTED.to_string(),
1380 export_forensic_bundle: IMPLEMENTED.to_string(),
1381 verify_forensic_bundle: IMPLEMENTED.to_string(),
1382 }
1383 }
1384}
1385
1386fn default_capability_forensic() -> CapabilityForensic {
1387 CapabilityForensic::current()
1388}
1389
1390/// v0.7.0 L3-5 — substrate-rules governance capability surface.
1391///
1392/// Surfaces the L1-6 activation posture honestly:
1393///
1394/// - `rules_engine`: `"operator_signed"` because the L1-6 loader
1395/// refuses to honour any `enabled = 1` rule that is not
1396/// `attest_level = 'operator_signed'` and whose signature does not
1397/// verify against the active operator pubkey
1398/// ([`crate::governance::rules_store`] L1-6 audit).
1399/// - `enforced_actions`: the actual variant set in
1400/// [`crate::governance::agent_action::AgentAction`] minus the
1401/// `Custom` extension point (extension points are not
1402/// substrate-enforced). v0.7.0 ships **four** action kinds at the
1403/// harness-mediated PreToolUse boundary.
1404/// - `bypass_impossibility_tests`: count of `#[test]` functions in
1405/// [`tests/governance_l16_activation.rs`] verifying the
1406/// bypass-impossibility properties (signature-required, tampered-sig
1407/// rejected, direct-enabled-flip rejected, keygen 0600, idempotent
1408/// sign-seed, rotated-key invalidates). The number reflects the test
1409/// file as of v0.7.0 — bumping it requires an audit pass and a
1410/// matching test addition.
1411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1412pub struct CapabilityGovernance {
1413 pub rules_engine: String,
1414 pub enforced_actions: Vec<String>,
1415 pub bypass_impossibility_tests: u32,
1416 /// v0.7.0 SEC-2 (Cluster D, issue #767) — `true` when an operator
1417 /// pubkey is resolved (env var or `~/.config/ai-memory/operator.key.pub`)
1418 /// AND therefore the L1-6 loader is in attest-enforcing mode (every
1419 /// `enabled = 1` row MUST be operator-signed to fire). `false` when
1420 /// the substrate is in pre-L1-6 / fail-OPEN compat mode — every
1421 /// enabled rule passes through without signature verification.
1422 ///
1423 /// Clients that need to display the deployment's enforcement
1424 /// posture (operator dashboard, MCP-inspect tool, capabilities
1425 /// summary) can render this flag verbatim. Defaults to `false`
1426 /// for envelopes serialised before SEC-2 to preserve wire
1427 /// compatibility.
1428 #[serde(default)]
1429 pub l1_6_attest: bool,
1430}
1431
1432/// v0.7.0 L1-6 — the canonical agent-external action kinds the
1433/// substrate gates via the operator-signed rules engine. Matches the
1434/// variant set in [`crate::governance::agent_action::AgentAction`]
1435/// (minus the open-ended `Custom` extension point).
1436///
1437/// #1605 — the values are the snake_case **wire tags** from
1438/// [`crate::governance::agent_action::action_kinds`] (the #1558 SSOT
1439/// the `memory_check_agent_action` MCP parser, the CLI `rules test`
1440/// parser, and the `governance_rules.kind` column all share), NOT the
1441/// Rust variant names. The pre-#1605 list advertised `"Bash"` /
1442/// `"FilesystemWrite"` / … — tokens the kind parser refuses — so a
1443/// caller following capabilities verbatim got `unknown kind`.
1444///
1445/// MemoryWrite is intentionally NOT in this list — substrate-internal
1446/// memory writes are gated by the K9 `Op` pipeline
1447/// ([`crate::governance::Op`]) which is a separate, substrate-
1448/// authoritative surface. The two engines have different enforcement
1449/// semantics; honest reporting keeps them on separate fields rather
1450/// than conflating them under one label. The L3-5 audit comment in
1451/// `tests/capabilities_v3_l3_5.rs` documents the carry-forward.
1452pub const ENFORCED_AGENT_ACTIONS: &[&str] = &[
1453 crate::governance::agent_action::action_kinds::BASH,
1454 crate::governance::agent_action::action_kinds::FILESYSTEM_WRITE,
1455 crate::governance::agent_action::action_kinds::NETWORK_REQUEST,
1456 crate::governance::agent_action::action_kinds::PROCESS_SPAWN,
1457];
1458
1459/// v0.7.0 L1-6 — number of bypass-impossibility tests pinning the
1460/// rules-engine activation posture. Tracks the `#[test]` count in
1461/// `tests/governance_l16_activation.rs`. Bumping this requires both an
1462/// audit and a matching test landing in that file.
1463pub const GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS: u32 = 6;
1464
1465impl CapabilityGovernance {
1466 /// Build the L3-5 governance capability from the live constants.
1467 #[must_use]
1468 pub fn current() -> Self {
1469 Self {
1470 rules_engine: "operator_signed".to_string(),
1471 enforced_actions: ENFORCED_AGENT_ACTIONS
1472 .iter()
1473 .map(|s| (*s).to_string())
1474 .collect(),
1475 bypass_impossibility_tests: GOVERNANCE_BYPASS_IMPOSSIBILITY_TESTS,
1476 // SEC-2 — reflect the live pubkey-resolution state at
1477 // envelope construction time. The pubkey lookup is
1478 // filesystem + env; cheap relative to the rest of the
1479 // capabilities-v3 build path.
1480 l1_6_attest: crate::governance::rules_store::l1_6_attest_active(),
1481 }
1482 }
1483}
1484
1485fn default_capability_governance() -> CapabilityGovernance {
1486 CapabilityGovernance::current()
1487}
1488
1489/// v0.7.0 WT-1-G — atomisation capability surface.
1490///
1491/// WT-1 ships substrate-native decomposition of long memories into
1492/// atomic propositions. The parent memory is archived (`archived_at`
1493/// stamped, `atomised_into = N`) and `N` first-class atomic children
1494/// land with `atom_of` back-pointers and a signed `derives_from`
1495/// `MemoryLink`. Each sub-field below names a real operator-facing
1496/// surface in this binary; the round-trip is honest — the values are
1497/// `"implemented"` only when the engine, hook, and wrapper code are
1498/// all wired.
1499///
1500/// Field → implementation anchor map:
1501///
1502/// - `tool`: MCP `memory_atomise` (Family::Power). Defined in
1503/// [`crate::mcp::tools::atomise`] + registered in
1504/// [`crate::mcp::registry`]. WT-1-C landed it.
1505/// - `cli`: `ai-memory atomise <memory_id>` subcommand. Wrapper lives
1506/// in [`crate::cli::commands::atomise`]. WT-1-F landed it.
1507/// - `auto`: namespace-policy-gated `auto_atomise` pre_store hook.
1508/// The hook in [`crate::hooks::pre_store::auto_atomise`] is
1509/// non-blocking (detached worker thread) and fires only when the
1510/// namespace standard's `metadata.governance.auto_atomise = true`.
1511/// WT-1-D landed it.
1512/// - `recall_preference`: recall surfaces atoms in place of an
1513/// archived parent via the SQL guard
1514/// `AND NOT (archived_at IS NOT NULL AND atomised_into > 0)`.
1515/// WT-1-E landed it.
1516/// - `forensic`: forensic bundle export includes the parent → atoms
1517/// chain envelope so a downstream auditor reconstructs the
1518/// decomposition offline. WT-1-E landed it.
1519/// - `curator`: production `LlmCurator` uses the Gemma 4 prompt
1520/// with `tiktoken-rs::cl100k_base` token-budget validation and
1521/// the audit-honest STOP discipline (no retry after a parse-OK
1522/// verdict). WT-1-B landed it.
1523#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1524pub struct CapabilityAtomisation {
1525 /// MCP `memory_atomise` tool — `"implemented"` once the tool is
1526 /// registered and the [`crate::mcp::tools::atomise`] handler is
1527 /// wired against [`crate::atomisation::Atomiser`].
1528 pub tool: String,
1529 /// `ai-memory atomise` CLI subcommand — `"implemented"` once the
1530 /// wrapper in [`crate::cli::commands::atomise`] is dispatched
1531 /// from `daemon_runtime::Command::Atomise`.
1532 pub cli: String,
1533 /// Namespace-policy-gated auto-atomisation pre_store hook —
1534 /// `"implemented"` when [`crate::hooks::pre_store::auto_atomise`]
1535 /// is compiled and the store handlers call
1536 /// `maybe_enqueue_auto_atomise` after a successful insert.
1537 pub auto: String,
1538 /// Recall-time atom preference — `"implemented"` when the recall
1539 /// SQL carries the
1540 /// `AND NOT (archived_at IS NOT NULL AND atomised_into > 0)`
1541 /// guard so atomised parents stop surfacing in their atoms'
1542 /// place. WT-1-E.
1543 pub recall_preference: String,
1544 /// Forensic chain envelope — `"implemented"` when the forensic
1545 /// bundle exporter ([`crate::forensic::bundle::build`]) walks
1546 /// `atom_of` back-pointers to include the parent → atoms chain
1547 /// in the bundle. WT-1-E.
1548 pub forensic: String,
1549 /// LLM curator — `"implemented"` once
1550 /// [`crate::atomisation::curator::LlmCurator`] is the production
1551 /// `Curator` impl driving the atomisation engine (Gemma 4 prompt,
1552 /// tiktoken-rs cl100k token-budget validation, audit-honest STOP).
1553 /// WT-1-B.
1554 pub curator: String,
1555 /// Memory-link relation that anchors the atom → parent edge.
1556 /// Always `"derives_from"`, matching
1557 /// [`crate::models::MemoryLinkRelation::DerivesFrom`]. Distinct
1558 /// from `related_to` / `supersedes` / `contradicts` — the
1559 /// atomisation engine writes this edge specifically, and
1560 /// downstream consumers can filter on the relation to walk
1561 /// decomposition lineage without reflection-chain noise.
1562 pub link_relation: String,
1563}
1564
1565impl CapabilityAtomisation {
1566 /// Build the WT-1-G atomisation capability surface from real,
1567 /// code-anchored values. Every `"implemented"` here is a claim
1568 /// pinned by [`tests/capabilities_v3_l3_5.rs`] and walked back to
1569 /// a registered MCP tool / CLI verb / hook module / SQL guard.
1570 #[must_use]
1571 pub fn current() -> Self {
1572 Self {
1573 tool: IMPLEMENTED.to_string(),
1574 cli: IMPLEMENTED.to_string(),
1575 auto: IMPLEMENTED.to_string(),
1576 recall_preference: IMPLEMENTED.to_string(),
1577 forensic: IMPLEMENTED.to_string(),
1578 curator: IMPLEMENTED.to_string(),
1579 link_relation: "derives_from".to_string(),
1580 }
1581 }
1582}
1583
1584fn default_capability_atomisation() -> CapabilityAtomisation {
1585 CapabilityAtomisation::current()
1586}
1587
1588// ---------------------------------------------------------------------------
1589// v0.7.x Form 6 — MemoryKind Batman-vocabulary capability surface (#759)
1590// ---------------------------------------------------------------------------
1591
1592/// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1593/// capability surface. Names the recall-filter / auto-classify
1594/// surfaces shipped under Form 6.
1595///
1596/// Field → implementation anchor map:
1597///
1598/// - `vocabulary`: the complete enumerated vocabulary the substrate
1599/// accepts on the `memory_kind` column. Always
1600/// `["observation", "reflection", "persona", "concept", "entity",
1601/// "claim", "relation", "event", "conversation", "decision"]` in
1602/// v0.7.x — anchored at compile time by
1603/// [`crate::models::MemoryKind::all`].
1604/// - `recall_filter`: MCP `memory_recall` and HTTP recall accept a
1605/// `kinds` parameter (CSV string or JSON array). `"implemented"`
1606/// once the param is plumbed into [`crate::mcp::tools::recall`]
1607/// and [`crate::handlers::http::recall_response`].
1608/// - `cli_filter`: `ai-memory recall --kind concept,entity` CLI
1609/// flag. `"implemented"` once the flag is wired in
1610/// [`crate::cli::recall::RecallArgs`].
1611/// - `auto_classify`: the namespace-policy-gated
1612/// `pre_store::auto_classify_kind` hook. `"implemented"` once
1613/// the hook module is compiled and `memory_store` calls
1614/// [`crate::hooks::pre_store::maybe_auto_classify`] after policy
1615/// resolution.
1616/// - `auto_classify_modes`: enumerated policy modes the operator
1617/// may set. Always `["off", "regex_only", "regex_then_llm"]` —
1618/// anchored against [`crate::models::MemoryKindAutoClassify`].
1619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1620pub struct CapabilityMemoryKindVocab {
1621 /// Complete enumerated vocabulary the substrate accepts on the
1622 /// `memory_kind` column. Compile-anchored.
1623 pub vocabulary: Vec<String>,
1624 /// MCP `memory_recall` + HTTP recall `kinds` param wiring.
1625 pub recall_filter: String,
1626 /// CLI `--kind` flag wiring.
1627 pub cli_filter: String,
1628 /// Namespace-policy-gated auto-classify pre_store hook wiring.
1629 pub auto_classify: String,
1630 /// Enumerated auto-classify policy modes (`off` / `regex_only` /
1631 /// `regex_then_llm`). Compile-anchored.
1632 pub auto_classify_modes: Vec<String>,
1633}
1634
1635impl CapabilityMemoryKindVocab {
1636 /// Build the Form 6 memory-kind-vocab capability surface from
1637 /// real, code-anchored values. Every `"implemented"` here is a
1638 /// claim pinned by [`tests/form_6_memorykind_vocab.rs`].
1639 #[must_use]
1640 pub fn current() -> Self {
1641 Self {
1642 vocabulary: crate::models::MemoryKind::all()
1643 .iter()
1644 .map(|k| k.as_str().to_string())
1645 .collect(),
1646 recall_filter: IMPLEMENTED.to_string(),
1647 cli_filter: IMPLEMENTED.to_string(),
1648 auto_classify: IMPLEMENTED.to_string(),
1649 auto_classify_modes: vec![
1650 "off".to_string(),
1651 "regex_only".to_string(),
1652 "regex_then_llm".to_string(),
1653 ],
1654 }
1655 }
1656}
1657
1658fn default_capability_memory_kind_vocab() -> CapabilityMemoryKindVocab {
1659 CapabilityMemoryKindVocab::current()
1660}
1661
1662// ---------------------------------------------------------------------------
1663// v0.7.0 Form 5 (issue #758) — auto-confidence + shadow-mode +
1664// calibration tooling capability surface.
1665// ---------------------------------------------------------------------------
1666
1667/// v0.7.0 Form 5 — operator-facing confidence-calibration capability
1668/// surface. Names every Form-5 substrate the binary actually ships:
1669///
1670/// - `auto_derive`: the [`crate::confidence::derive`] engine
1671/// (deterministic auto-confidence formula). Opt-in via
1672/// `AI_MEMORY_AUTO_CONFIDENCE=1` — the field reports `"implemented"`
1673/// because the engine compiles in unconditionally; the env-var gate
1674/// is the operator control plane.
1675/// - `shadow_mode`: the [`crate::confidence::shadow`] pipeline backed
1676/// by the `confidence_shadow_observations` table (schema v39 sqlite /
1677/// v38 postgres). Opt-in via `AI_MEMORY_CONFIDENCE_SHADOW=1`.
1678/// - `freshness_decay`: the [`crate::confidence::decay::decayed`]
1679/// exponential decay model. Opt-in via `AI_MEMORY_CONFIDENCE_DECAY=1`
1680/// or per-namespace `confidence_decay_half_life_days` policy.
1681/// - `calibration_cli`: the `ai-memory calibrate confidence
1682/// --from-shadow` driver verb that scans the observation table and
1683/// emits per-(namespace, source) baselines.
1684/// - `calibration_tool`: the `memory_calibrate_confidence` MCP tool
1685/// (Family::Power) — operator-callable equivalent of the CLI driver.
1686/// - `signals_schema`: the wire-shape discriminator for the JSON
1687/// envelope stored on `memories.confidence_signals`. Always
1688/// `"v1"` in v0.7.0 — bumped when the [`crate::models::ConfidenceSignals`]
1689/// struct gains a new field.
1690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1691pub struct CapabilityConfidenceCalibration {
1692 /// `"implemented"` once [`crate::confidence::derive`] is wired into
1693 /// the substrate (it compiles in regardless of feature flag).
1694 pub auto_derive: String,
1695 /// `"implemented"` once [`crate::confidence::shadow`] is wired
1696 /// (Form 5).
1697 pub shadow_mode: String,
1698 /// `"implemented"` once [`crate::confidence::decay`] is wired
1699 /// (Form 5).
1700 pub freshness_decay: String,
1701 /// `"implemented"` once the `ai-memory calibrate confidence` CLI
1702 /// driver registers under [`crate::cli`].
1703 pub calibration_cli: String,
1704 /// `"implemented"` once the `memory_calibrate_confidence` MCP
1705 /// tool registers under Family::Power.
1706 pub calibration_tool: String,
1707 /// Wire-shape discriminator for `memories.confidence_signals`.
1708 /// Always `"v1"` in v0.7.0.
1709 pub signals_schema: String,
1710 /// Default freshness-decay half-life (days). 30 in v0.7.0; tunable
1711 /// per namespace via the `confidence_decay_half_life_days` policy.
1712 pub default_half_life_days: f64,
1713 /// v0.7.0 Gap 4 (#887) — derived-tier thresholds. MCP callers
1714 /// reading this surface know how the substrate buckets the
1715 /// `confidence` real into `confirmed` / `likely` / `ambiguous`
1716 /// without re-deriving the breakpoints. Stable; bumping is a
1717 /// wire-level break (see [`crate::models::ConfidenceTier`]).
1718 /// `#[serde(default)]` keeps pre-Gap-4 capability consumers
1719 /// reading newer payloads from breaking.
1720 #[serde(default)]
1721 pub tier_thresholds: ConfidenceTierThresholds,
1722}
1723
1724impl CapabilityConfidenceCalibration {
1725 /// Build the Form 5 capability surface from real, code-anchored
1726 /// values. Every `"implemented"` here is a claim pinned by
1727 /// `tests/form_5_confidence_calibration.rs` and walked back to a
1728 /// registered MCP tool / CLI verb / module file.
1729 #[must_use]
1730 pub fn current() -> Self {
1731 Self {
1732 auto_derive: IMPLEMENTED.to_string(),
1733 shadow_mode: IMPLEMENTED.to_string(),
1734 freshness_decay: IMPLEMENTED.to_string(),
1735 calibration_cli: IMPLEMENTED.to_string(),
1736 calibration_tool: IMPLEMENTED.to_string(),
1737 signals_schema: "v1".to_string(),
1738 default_half_life_days: crate::confidence::DEFAULT_HALF_LIFE_DAYS,
1739 tier_thresholds: ConfidenceTierThresholds::default(),
1740 }
1741 }
1742}
1743
1744fn default_capability_confidence_calibration() -> CapabilityConfidenceCalibration {
1745 CapabilityConfidenceCalibration::current()
1746}
1747
1748// ---------------------------------------------------------------------------
1749// Capabilities v1 — legacy shape retained for backward compat
1750// ---------------------------------------------------------------------------
1751
1752/// Legacy (v1) capabilities shape — the structure shipped before the
1753/// v0.6.3.1 honesty patch. Returned only when a client opts in via
1754/// `Accept-Capabilities: v1` (HTTP) or the MCP `accept` argument set
1755/// to `"v1"`. Default response is v2.
1756///
1757/// The v1 schema is frozen — do not extend it. New fields go into v2
1758/// (see [`Capabilities`]).
1759#[derive(Debug, Clone, Serialize, Deserialize)]
1760pub struct CapabilitiesV1 {
1761 pub tier: String,
1762 pub version: String,
1763 pub features: CapabilityFeaturesV1,
1764 pub models: CapabilityModels,
1765}
1766
1767/// Legacy v1 feature-flag block. Notably, `memory_reflection` is a
1768/// `bool` here (it became a `PlannedFeature` object in v2).
1769#[allow(clippy::struct_excessive_bools)]
1770#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct CapabilityFeaturesV1 {
1772 pub keyword_search: bool,
1773 pub semantic_search: bool,
1774 pub hybrid_recall: bool,
1775 pub query_expansion: bool,
1776 pub auto_consolidation: bool,
1777 pub auto_tagging: bool,
1778 pub contradiction_analysis: bool,
1779 pub cross_encoder_reranking: bool,
1780 pub memory_reflection: bool,
1781 #[serde(default)]
1782 pub embedder_loaded: bool,
1783}
1784
1785impl Capabilities {
1786 /// Project the v2 report down to the legacy v1 shape. Used to
1787 /// honour `Accept-Capabilities: v1` from older clients.
1788 ///
1789 /// `memory_reflection` collapses from `{planned, enabled}` to a
1790 /// single bool (`enabled` value). All v2-only fields
1791 /// (`recall_mode_active`, `reranker_active`, `permissions`,
1792 /// `hooks`, `compaction`, `approval`, `transcripts`) are dropped.
1793 #[must_use]
1794 pub fn to_v1(&self) -> CapabilitiesV1 {
1795 CapabilitiesV1 {
1796 tier: self.tier.clone(),
1797 version: self.version.clone(),
1798 features: CapabilityFeaturesV1 {
1799 keyword_search: self.features.keyword_search,
1800 semantic_search: self.features.semantic_search,
1801 hybrid_recall: self.features.hybrid_recall,
1802 query_expansion: self.features.query_expansion,
1803 auto_consolidation: self.features.auto_consolidation,
1804 auto_tagging: self.features.auto_tagging,
1805 contradiction_analysis: self.features.contradiction_analysis,
1806 cross_encoder_reranking: self.features.cross_encoder_reranking,
1807 memory_reflection: self.features.memory_reflection.enabled,
1808 embedder_loaded: self.features.embedder_loaded,
1809 },
1810 models: self.models.clone(),
1811 }
1812 }
1813
1814 /// v0.7.0 (A1+A2+A3+A4): project the report into the v3 shape.
1815 ///
1816 /// v3 = v2 +
1817 /// - top-level `summary` (A1) — terse description of operational
1818 /// access plus the three named recovery paths.
1819 /// - top-level `to_describe_to_user` (A2) — plain-English
1820 /// end-user-facing sentence the LLM should repeat verbatim
1821 /// when asked "what tools do you have?". No MCP jargon.
1822 /// - top-level `tools` (A3) — per-tool array carrying name,
1823 /// family, `loaded`, and `callable_now`. `callable_now`
1824 /// combines profile-side loaded-state with the
1825 /// `[mcp.allowlist]` agent-can-call decision so an LLM that
1826 /// keeps a manifest cache doesn't need to ask twice to know
1827 /// whether a tool will resolve.
1828 /// - top-level `agent_permitted_families` (A4, optional) — when
1829 /// the `[mcp.allowlist]` is enabled AND an `agent_id` is
1830 /// provided, lists the family names the requesting agent is
1831 /// allowed to access (collapses every callable_now=true entry's
1832 /// family to a unique list). When the allowlist is disabled or
1833 /// no agent_id is provided, the field is omitted from the wire
1834 /// (so v2-shaped consumers see no churn from A4 alone).
1835 ///
1836 /// All four are computed by the caller from the live `Profile` +
1837 /// `McpConfig` + `agent_id` state because the [`Capabilities`]
1838 /// struct itself doesn't know which families the MCP server
1839 /// actually advertised or which agent is asking.
1840 ///
1841 /// A5 bumps the default wire shape to v3. v2 stays supported
1842 /// indefinitely.
1843 #[must_use]
1844 pub fn to_v3(
1845 &self,
1846 summary: String,
1847 to_describe_to_user: String,
1848 tools: Vec<ToolEntry>,
1849 agent_permitted_families: Option<Vec<String>>,
1850 your_harness_supports_deferred_registration: Option<bool>,
1851 ) -> CapabilitiesV3 {
1852 CapabilitiesV3 {
1853 schema_version: "3".to_string(),
1854 summary,
1855 to_describe_to_user,
1856 tools,
1857 agent_permitted_families,
1858 your_harness_supports_deferred_registration,
1859 tier: self.tier.clone(),
1860 version: self.version.clone(),
1861 features: self.features.clone(),
1862 models: self.models.clone(),
1863 permissions: self.permissions.clone(),
1864 hooks: self.hooks.clone(),
1865 compaction: self.compaction.clone(),
1866 approval: self.approval.clone(),
1867 transcripts: self.transcripts.clone(),
1868 hnsw: self.hnsw.clone(),
1869 // v0.7 J1 — propagate the resolved KG backend tag verbatim.
1870 // None when no SAL adapter is wired (every pre-J2 build);
1871 // `Some("age" | "cte")` once the SAL handle is threaded.
1872 kg_backend: self.kg_backend.clone(),
1873 // L1-1 — propagate the memory-kind set verbatim.
1874 memory_kinds: self.memory_kinds.clone(),
1875 // L3-5 — four new substrate-honesty blocks. Built from
1876 // compile-time anchors (the per-block `::current()`
1877 // constructor) so the wire shape reflects the actual
1878 // implementation surface, not a static template.
1879 reflection: CapabilityReflection::current(),
1880 skills: CapabilitySkills::current(),
1881 forensic: CapabilityForensic::current(),
1882 governance: CapabilityGovernance::current(),
1883 // v0.7.0 WT-1-G — operator-facing atomisation surface.
1884 // Anchored at compile time against the WT-1-{A..F} ships
1885 // (engine, curator, hook, recall guard, forensic bundle,
1886 // MCP tool, CLI subcommand).
1887 atomisation: CapabilityAtomisation::current(),
1888 // v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1889 // vocabulary surface. Anchored at compile time against the
1890 // [`crate::models::MemoryKind`] enum + the recall-filter /
1891 // CLI / auto-classify wiring shipped under Form 6.
1892 memory_kind_vocab: CapabilityMemoryKindVocab::current(),
1893 // v0.7.0 Form 5 (issue #758) — confidence-calibration
1894 // surface. Anchored at compile time against the
1895 // `crate::confidence` module (derive, shadow, decay,
1896 // calibrate), the `ai-memory calibrate confidence` CLI
1897 // subcommand, and the `memory_calibrate_confidence` MCP
1898 // tool.
1899 confidence_calibration: CapabilityConfidenceCalibration::current(),
1900 // v0.7.0 #973 Item C — do-calculus / Ortega-de-Freitas
1901 // narrative surface. Helper does the source-tree honesty
1902 // check at the comment site; see the helper's docstring.
1903 provenance_substrate_layer: default_capability_provenance_substrate_layer(),
1904 }
1905 }
1906}
1907
1908/// v0.7.0 A3 — per-tool entry in the capabilities-v3 `tools` array.
1909///
1910/// `loaded` mirrors `Profile::loads(name)` — true when the active
1911/// profile would advertise this tool in `tools/list`.
1912///
1913/// `callable_now` is the AND of `loaded` with the
1914/// `[mcp.allowlist]` per-agent gate. When the allowlist is disabled
1915/// (no `[mcp.allowlist]` table or empty table), `callable_now ==
1916/// loaded`. When the allowlist is active and the requesting agent
1917/// has no entry granting the tool's family, `callable_now == false`
1918/// even though `loaded == true`.
1919///
1920/// LLMs that cache the v3 manifest can use this to skip a doomed
1921/// JSON-RPC call rather than discover -32601 the hard way.
1922#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1923pub struct ToolEntry {
1924 /// Fully-qualified MCP tool name (e.g., `memory_store`).
1925 pub name: String,
1926 /// Family the tool belongs to. Always one of the eight canonical
1927 /// family names (`core`, `lifecycle`, `graph`, etc.) or
1928 /// `"always_on"` for the `memory_capabilities` bootstrap which
1929 /// doesn't sit in any single family from a registration standpoint.
1930 pub family: String,
1931 /// Whether the active profile's family set includes this tool's
1932 /// family (i.e., it appears in `tools/list`).
1933 pub loaded: bool,
1934 /// `loaded && agent_can_call(agent_id, family)`. When the
1935 /// `[mcp.allowlist]` is disabled, `callable_now == loaded`.
1936 pub callable_now: bool,
1937 /// v0.7.0 issue #803 — 0-2 worked examples for the tool.
1938 /// `skip_serializing_if = "Vec::is_empty"` strips the field
1939 /// for any tool without curated examples.
1940 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1941 pub examples: Vec<ToolExample>,
1942}
1943
1944/// v0.7.0 issue #803 — single worked example for `tools[].examples`.
1945#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1946pub struct ToolExample {
1947 pub call: serde_json::Value,
1948 pub description: String,
1949}
1950
1951// ---------------------------------------------------------------------------
1952// Capabilities v3 — v0.7.0 attested-cortex schema (additive over v2)
1953// ---------------------------------------------------------------------------
1954
1955/// v0.7.0 capabilities schema (A1 increment). Additive over [`Capabilities`]
1956/// (v2): the top-level `summary` field carries a pre-computed,
1957/// plain-language description of the LLM's operational tool surface so
1958/// reasoning-class LLMs converge on accurate first-answer descriptions
1959/// without having to traverse `families[]` and count manually.
1960///
1961/// Wire selection: clients opt in via `accept="v3"` on the MCP
1962/// `memory_capabilities` call, or `Accept-Capabilities: v3` over HTTP
1963/// (HTTP wiring lands with A5). Default response remains v2 until A5
1964/// flips the default. v2 stays supported indefinitely.
1965///
1966/// Increment plan: A2 adds `to_describe_to_user`, A3 adds per-tool
1967/// `callable_now`, A4 adds `agent_permitted_families`. A5 bumps the
1968/// default wire shape and seals v3 as the recommended client target.
1969#[derive(Debug, Clone, Serialize, Deserialize)]
1970pub struct CapabilitiesV3 {
1971 /// Schema-version discriminator. Always `"3"` in v0.7.0.
1972 pub schema_version: String,
1973
1974 /// Pre-computed plain-language summary of operational access.
1975 /// Carries the loaded vs total tool counts under the active profile
1976 /// plus the three named recovery paths (`--profile`,
1977 /// `memory_load_family`, `memory_smart_load`). Computed at response
1978 /// time from the live profile state — never cached at build time
1979 /// because the count of advertised tools depends on the running
1980 /// server's `--profile` flag.
1981 pub summary: String,
1982
1983 /// v0.7.0 A2 — pre-computed end-user-facing sentence the LLM should
1984 /// repeat verbatim when an end-user asks "what tools do you have?".
1985 /// Distinct in tone from [`Self::summary`]: no MCP jargon, no
1986 /// `--profile` references, no JSON-RPC vocabulary. Reads as a
1987 /// normal sentence a human would say. See
1988 /// `docs/v0.7/canonical-phrasings.md` for the canonical
1989 /// substitution template + worked examples per profile.
1990 pub to_describe_to_user: String,
1991
1992 /// v0.7.0 A3 — per-tool array carrying name, family, `loaded`, and
1993 /// `callable_now`. `callable_now` combines profile-side
1994 /// loaded-state with the `[mcp.allowlist]` agent-can-call decision
1995 /// so an LLM that caches this manifest can skip a doomed JSON-RPC
1996 /// call rather than discovering -32601 the hard way. Order matches
1997 /// `tool_definitions()`'s registration walk so a sequential reader
1998 /// gets a stable presentation.
1999 pub tools: Vec<ToolEntry>,
2000
2001 /// v0.7.0 A4 — list of family names this agent is permitted to
2002 /// access via the `[mcp.allowlist]` gate. Present (with possibly
2003 /// an empty array) only when the allowlist is configured AND an
2004 /// `agent_id` was provided. Absent when the allowlist is disabled
2005 /// or no agent_id was provided — that absence is meaningful, not a
2006 /// drift, hence `Option<Vec<String>>` + `skip_serializing_if`.
2007 ///
2008 /// LLMs that keep a per-agent manifest cache can use this to
2009 /// short-circuit family-level decisions without iterating
2010 /// `tools[]` and counting unique families.
2011 #[serde(default, skip_serializing_if = "Option::is_none")]
2012 pub agent_permitted_families: Option<Vec<String>>,
2013
2014 /// v0.7.0 B4 — whether the active MCP harness exposes tools
2015 /// registered *after* the initial `tools/list` to the LLM. Computed
2016 /// at response time from the harness detected at the
2017 /// `initialize.clientInfo.name` handshake (see `crate::harness`).
2018 ///
2019 /// `Some(true)` only for Claude Code today (deferred registration
2020 /// via `ToolSearch`). `Some(false)` for every other named harness.
2021 /// `None` (omitted from the wire via `skip_serializing_if`) when
2022 /// no `clientInfo` was captured — typically HTTP callers, or an
2023 /// MCP client that issued `memory_capabilities` before
2024 /// `initialize` (malformed but defensively handled by absence).
2025 ///
2026 /// Track B's runtime loaders (B1 `memory_load_family`, B2
2027 /// `memory_smart_load`) key off this bit to shape their
2028 /// `to_invoke` text — on `false` harnesses they advise the LLM to
2029 /// ask the operator for a `--profile <family>` restart rather
2030 /// than expect the new tools to appear mid-session.
2031 #[serde(default, skip_serializing_if = "Option::is_none")]
2032 pub your_harness_supports_deferred_registration: Option<bool>,
2033
2034 pub tier: String,
2035 pub version: String,
2036 pub features: CapabilityFeatures,
2037 pub models: CapabilityModels,
2038 pub permissions: CapabilityPermissions,
2039 pub hooks: CapabilityHooks,
2040 pub compaction: CapabilityCompaction,
2041 pub approval: CapabilityApproval,
2042 pub transcripts: CapabilityTranscripts,
2043
2044 #[serde(default)]
2045 pub hnsw: CapabilityHnsw,
2046
2047 /// v0.7 J1 — knowledge-graph backend tag forwarded from the v2
2048 /// projection. `Some("age" | "cte")` once the SAL handle is
2049 /// threaded through `AppState`; `None` while no SAL adapter is
2050 /// wired. Skipped from the JSON wire when `None` so older clients
2051 /// that don't know the field round-trip cleanly.
2052 #[serde(default, skip_serializing_if = "Option::is_none")]
2053 pub kg_backend: Option<String>,
2054
2055 /// L1-1 (v0.7.0) — typed memory-kind set. Forwarded from the v2
2056 /// projection's `memory_kinds` field. Always
2057 /// `["observation", "reflection"]` for v0.7.0.
2058 ///
2059 /// **L3-5 honesty note.** The grand-slam spec called for a third
2060 /// `"goal"` kind here, but the [`crate::models::memory::MemoryKind`]
2061 /// enum in this binary only carries `Observation` and `Reflection`.
2062 /// Per the operator's "every reported field maps to real
2063 /// implementation" directive, the v3 surface reports exactly what
2064 /// the substrate enforces — the `goal` kind is deferred to the
2065 /// tracker (`a4f8d465`) for a v0.8.0 wave that lands the enum
2066 /// variant + migration + write-path coverage. Reporting it here
2067 /// today would be theatrical.
2068 #[serde(default = "default_memory_kinds")]
2069 pub memory_kinds: Vec<String>,
2070
2071 /// v0.7.0 L3-5 — recursive-learning capability surface. Every
2072 /// sub-field anchors a real implementation in this binary; see
2073 /// [`CapabilityReflection`] for the per-field audit anchors.
2074 #[serde(default = "default_capability_reflection")]
2075 pub reflection: CapabilityReflection,
2076
2077 /// v0.7.0 L3-5 — Agent-Skills capability surface. Lists the seven
2078 /// registered `memory_skill_*` MCP tools; the round-trip guarantee
2079 /// is pinned by `tests/skill_test.rs`. See [`CapabilitySkills`].
2080 #[serde(default = "default_capability_skills")]
2081 pub skills: CapabilitySkills,
2082
2083 /// v0.7.0 L3-5 — forensic-evidence CLI surface. Names the three
2084 /// driver verbs that this binary actually ships
2085 /// (`verify-reflection-chain`, `export-forensic-bundle`,
2086 /// `verify-forensic-bundle`). See [`CapabilityForensic`].
2087 #[serde(default = "default_capability_forensic")]
2088 pub forensic: CapabilityForensic,
2089
2090 /// v0.7.0 L3-5 — substrate-rules governance surface. Honestly
2091 /// labelled `"operator_signed"` because the L1-6 loader refuses
2092 /// to honour unsigned rules. See [`CapabilityGovernance`].
2093 #[serde(default = "default_capability_governance")]
2094 pub governance: CapabilityGovernance,
2095
2096 /// v0.7.0 WT-1-G — atomisation capability surface. Names the six
2097 /// operator-facing atomisation surfaces (`tool` / `cli` / `auto` /
2098 /// `recall_preference` / `forensic` / `curator`) plus the
2099 /// `derives_from` link relation that anchors atom → parent
2100 /// lineage. See [`CapabilityAtomisation`] for the per-field
2101 /// implementation anchor map.
2102 ///
2103 /// Additive over the L3-5 surface — pre-WT-1-G v3 payloads still
2104 /// deserialise cleanly (the `default_capability_atomisation`
2105 /// helper resolves to the current-implementation snapshot for any
2106 /// payload missing the field).
2107 #[serde(default = "default_capability_atomisation")]
2108 pub atomisation: CapabilityAtomisation,
2109
2110 /// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
2111 /// vocabulary capability surface. Names the recall-filter +
2112 /// auto-classify surfaces shipped under Form 6 and enumerates
2113 /// the substrate's full set of recognised `memory_kind` values.
2114 /// See [`CapabilityMemoryKindVocab`].
2115 ///
2116 /// Additive over the WT-1-G surface — pre-Form-6 v3 payloads
2117 /// deserialise cleanly via the
2118 /// `default_capability_memory_kind_vocab` helper.
2119 #[serde(default = "default_capability_memory_kind_vocab")]
2120 pub memory_kind_vocab: CapabilityMemoryKindVocab,
2121
2122 /// v0.7.0 Form 5 (issue #758) — confidence-calibration capability
2123 /// surface. Names the five operator-facing Form-5 substrates
2124 /// (`auto_derive` / `shadow_mode` / `freshness_decay` /
2125 /// `calibration_cli` / `calibration_tool`) plus the
2126 /// `signals_schema` wire-shape discriminator. See
2127 /// [`CapabilityConfidenceCalibration`] for the per-field anchor
2128 /// map.
2129 ///
2130 /// Additive over the WT-1-G surface — pre-Form-5 v3 payloads still
2131 /// deserialise cleanly because of the
2132 /// `default_capability_confidence_calibration` helper.
2133 #[serde(default = "default_capability_confidence_calibration")]
2134 pub confidence_calibration: CapabilityConfidenceCalibration,
2135
2136 /// v0.7.0 #973 Item C — narrative summary of the substrate's
2137 /// do-calculus posture.
2138 #[serde(default = "default_capability_provenance_substrate_layer")]
2139 pub provenance_substrate_layer: CapabilityProvenanceSubstrateLayer,
2140}
2141
2142/// v0.7.0 #973 Item C — substrate-layer provenance posture. Lets an
2143/// LLM agent self-describe ai-memory's do-calculus
2144/// intervention/observation distinction (Pearl 2009) per Ortega &
2145/// de Freitas (2026) framing. Honesty discipline: every
2146/// `enforcement_layers` entry must map to a shipped substrate
2147/// primitive in source.
2148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2149pub struct CapabilityProvenanceSubstrateLayer {
2150 #[serde(default)]
2151 pub posture: String,
2152 #[serde(default)]
2153 pub summary: String,
2154 #[serde(default)]
2155 pub enforcement_layers: Vec<String>,
2156 #[serde(default)]
2157 pub honest_limitations: Vec<String>,
2158 #[serde(default)]
2159 pub spec_references: SpecReferences,
2160}
2161
2162/// v0.7.0 #973 Item C — academic citations. Vendor-neutral.
2163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2164pub struct SpecReferences {
2165 #[serde(default)]
2166 pub do_calculus: String,
2167 #[serde(default)]
2168 pub interactional_agency: String,
2169}
2170
2171#[must_use]
2172pub fn default_capability_provenance_substrate_layer() -> CapabilityProvenanceSubstrateLayer {
2173 CapabilityProvenanceSubstrateLayer {
2174 posture: "do_calculus_aligned".to_string(),
2175 summary: "ai-memory implements the do-calculus intervention/observation \
2176 distinction at the substrate layer via Form 4 fact-provenance, \
2177 Form 6 MemoryKind vocabulary, Form 7 agent-EXTERNAL governance, \
2178 the V-4 signed-events cross-row hash chain, and the seven Gap \
2179 provenance framework; stops cross-session delusion amplification \
2180 but not intra-session hallucination (consumer LLM responsibility)."
2181 .to_string(),
2182 enforcement_layers: vec![
2183 "form_4_fact_provenance".to_string(),
2184 "form_6_memory_kind".to_string(),
2185 "form_7_agent_external_governance".to_string(),
2186 "signed_events_v4_chain".to_string(),
2187 "seven_gap_framework".to_string(),
2188 ],
2189 honest_limitations: vec![
2190 "intra_session_hallucination_is_consumer_responsibility".to_string(),
2191 "federation_reliability_via_dlq_not_silent_drop".to_string(),
2192 ],
2193 spec_references: SpecReferences {
2194 do_calculus: "Pearl (2009)".to_string(),
2195 interactional_agency: "Ortega and de Freitas (2026)".to_string(),
2196 },
2197 }
2198}
2199
2200// ---------------------------------------------------------------------------
2201// TTL configuration
2202// ---------------------------------------------------------------------------
2203
2204/// Per-tier TTL overrides loaded from `[ttl]` section of config.toml.
2205#[allow(clippy::struct_field_names)]
2206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2207pub struct TtlConfig {
2208 /// Short-tier default TTL in seconds (default: 21600 = 6 hours)
2209 pub short_ttl_secs: Option<i64>,
2210 /// Mid-tier default TTL in seconds (default: 604800 = 7 days)
2211 pub mid_ttl_secs: Option<i64>,
2212 /// Long-tier TTL in seconds (default: none = never expires). Set >0 to add expiry.
2213 pub long_ttl_secs: Option<i64>,
2214 /// Short-tier TTL extension on access in seconds (default: 3600 = 1 hour)
2215 pub short_extend_secs: Option<i64>,
2216 /// Mid-tier TTL extension on access in seconds (default: 86400 = 1 day)
2217 pub mid_extend_secs: Option<i64>,
2218}
2219
2220/// Resolved TTL values after merging config overrides with compiled defaults.
2221#[derive(Debug, Clone)]
2222#[allow(clippy::struct_field_names)]
2223pub struct ResolvedTtl {
2224 pub short_ttl_secs: Option<i64>,
2225 pub mid_ttl_secs: Option<i64>,
2226 pub long_ttl_secs: Option<i64>,
2227 pub short_extend_secs: i64,
2228 pub mid_extend_secs: i64,
2229}
2230
2231impl Default for ResolvedTtl {
2232 fn default() -> Self {
2233 Self {
2234 short_ttl_secs: Tier::Short.default_ttl_secs(),
2235 mid_ttl_secs: Tier::Mid.default_ttl_secs(),
2236 long_ttl_secs: Tier::Long.default_ttl_secs(),
2237 short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
2238 mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
2239 }
2240 }
2241}
2242
2243/// Maximum configurable TTL: 10 years in seconds. Prevents integer overflow
2244/// when adding Duration to `Utc::now()`.
2245const MAX_TTL_SECS: i64 = 315_360_000;
2246
2247#[allow(dead_code)]
2248impl ResolvedTtl {
2249 /// Build from optional config overrides, falling back to compiled defaults.
2250 /// TTL values are clamped to `MAX_TTL_SECS` (10 years) to prevent overflow.
2251 /// Extension values are clamped to non-negative.
2252 pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
2253 let defaults = Self::default();
2254 let Some(c) = cfg else {
2255 return defaults;
2256 };
2257 let clamp_ttl = |v: i64| -> Option<i64> {
2258 if v <= 0 {
2259 None
2260 } else {
2261 Some(v.min(MAX_TTL_SECS))
2262 }
2263 };
2264 Self {
2265 short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
2266 mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
2267 long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
2268 short_extend_secs: c
2269 .short_extend_secs
2270 .unwrap_or(defaults.short_extend_secs)
2271 .max(0),
2272 mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
2273 }
2274 }
2275
2276 /// Get the default TTL for a given tier.
2277 pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
2278 match tier {
2279 Tier::Short => self.short_ttl_secs,
2280 Tier::Mid => self.mid_ttl_secs,
2281 Tier::Long => self.long_ttl_secs,
2282 }
2283 }
2284
2285 /// Get the TTL extension on access for a given tier.
2286 pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
2287 match tier {
2288 Tier::Short => Some(self.short_extend_secs),
2289 Tier::Mid => Some(self.mid_extend_secs),
2290 Tier::Long => None,
2291 }
2292 }
2293}
2294
2295// ---------------------------------------------------------------------------
2296// Transcript lifecycle (v0.7.0 I3) — per-namespace TTL + archive→prune
2297// ---------------------------------------------------------------------------
2298
2299/// Compiled-in default for the transcript TTL: 30 days. After this
2300/// many seconds elapse from `created_at` AND every memory that links
2301/// the transcript has expired (or been deleted), the I3 background
2302/// sweeper marks the transcript archived.
2303pub const DEFAULT_TRANSCRIPT_TTL_SECS: i64 = 2_592_000;
2304
2305/// Compiled-in default for the post-archive grace window: 7 days.
2306/// A transcript whose `archived_at` is older than this is hard-deleted
2307/// by the prune phase; the I2 join table is cleaned up via
2308/// `ON DELETE CASCADE`.
2309pub const DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS: i64 = crate::SECS_PER_WEEK;
2310
2311/// Maximum transcript TTL / grace clamp: 10 years in seconds. Mirrors
2312/// [`MAX_TTL_SECS`] above so the same overflow guard applies to the
2313/// transcript lifecycle math when the resolved value flows into a
2314/// `chrono::Duration`.
2315const MAX_TRANSCRIPT_LIFECYCLE_SECS: i64 = 315_360_000;
2316
2317/// `[transcripts]` block in `config.toml` — per-namespace TTL and
2318/// archive grace overrides for the I3 lifecycle sweeper.
2319///
2320/// ```toml
2321/// [transcripts]
2322/// default_ttl_secs = 2592000 # 30 days; archive after this when memories all expired
2323/// archive_grace_secs = 604800 # 7 days; prune this long after archive
2324///
2325/// [transcripts.namespaces."team/audit"]
2326/// default_ttl_secs = 31536000 # 1 year — compliance retention override
2327///
2328/// [transcripts.namespaces."ephemeral/*"]
2329/// default_ttl_secs = 86400 # 1 day — short-lived scratchpad
2330/// ```
2331///
2332/// Resolution: the sweeper picks the longest-prefix matching namespace
2333/// override (with literal `"*"` patterns last), falls back to the
2334/// global `default_ttl_secs` / `archive_grace_secs` on this struct,
2335/// and finally to the compiled defaults above.
2336#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2337pub struct TranscriptsConfig {
2338 /// Global default seconds-since-creation before the sweeper
2339 /// considers a transcript archive-eligible. `None` → compiled
2340 /// default ([`DEFAULT_TRANSCRIPT_TTL_SECS`] = 30 days).
2341 pub default_ttl_secs: Option<i64>,
2342 /// Global default seconds an archived transcript lingers before
2343 /// the prune phase deletes it. `None` → compiled default
2344 /// ([`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`] = 7 days).
2345 pub archive_grace_secs: Option<i64>,
2346 /// Per-namespace overrides keyed by namespace pattern. Patterns
2347 /// are matched literally first; a trailing `/*` selects every
2348 /// child namespace under the prefix; the bare `"*"` is the
2349 /// catch-all and is consulted last.
2350 pub namespaces: Option<std::collections::HashMap<String, TranscriptNamespaceConfig>>,
2351 /// v0.7.0 I1 cap (#628 agent-3 follow-up): the maximum number of
2352 /// bytes a single transcript may decompress to before
2353 /// `transcripts::fetch` rejects it as a decompression bomb. `None`
2354 /// → compiled default ([`crate::transcripts::MAX_DECOMPRESSED_BYTES`]
2355 /// = 16 MiB). Operators with legitimately larger transcripts
2356 /// raise the cap explicitly; the cap is per-call, so concurrent
2357 /// fetches consume up to N × this value of transient memory.
2358 pub max_decompressed_bytes: Option<usize>,
2359}
2360
2361/// Per-namespace overrides nested under
2362/// `[transcripts.namespaces."<pattern>"]`. Each field independently
2363/// overrides the [`TranscriptsConfig`] global default; an unset field
2364/// inherits.
2365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2366pub struct TranscriptNamespaceConfig {
2367 /// Namespace-specific TTL override.
2368 pub default_ttl_secs: Option<i64>,
2369 /// Namespace-specific archive-grace override.
2370 pub archive_grace_secs: Option<i64>,
2371 /// v0.7 I5 — opt in the namespace to the reference R5 pre_store
2372 /// transcript-extractor hook (`tools/transcript-extractor/`).
2373 /// Default `None` → disabled, matching the "default off" lesson
2374 /// from G3-G11. Operators that wire the extractor binary into
2375 /// their `hooks.toml` set this flag per namespace to gate the
2376 /// derived-memory expansion. `Some(false)` is identical to
2377 /// `None` and exists so an explicit "no, don't extract here"
2378 /// can be expressed alongside a wildcard `Some(true)`.
2379 #[serde(skip_serializing_if = "Option::is_none")]
2380 pub auto_extract: Option<bool>,
2381}
2382
2383/// Resolved transcript-lifecycle parameters for a single namespace.
2384/// Produced by [`TranscriptsConfig::resolve`] and consumed by the I3
2385/// sweeper to drive the archive + prune SQL.
2386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2387pub struct ResolvedTranscriptLifecycle {
2388 /// Seconds-since-creation before archive eligibility. Always
2389 /// positive and `<= MAX_TRANSCRIPT_LIFECYCLE_SECS`.
2390 pub default_ttl_secs: i64,
2391 /// Seconds an archived row lingers before prune. Always
2392 /// positive and `<= MAX_TRANSCRIPT_LIFECYCLE_SECS`.
2393 pub archive_grace_secs: i64,
2394}
2395
2396impl Default for ResolvedTranscriptLifecycle {
2397 fn default() -> Self {
2398 Self {
2399 default_ttl_secs: DEFAULT_TRANSCRIPT_TTL_SECS,
2400 archive_grace_secs: DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS,
2401 }
2402 }
2403}
2404
2405impl TranscriptsConfig {
2406 /// Resolve the lifecycle parameters for `namespace`.
2407 ///
2408 /// Precedence:
2409 /// 1. Exact match in `namespaces` (e.g. `"team/audit"`).
2410 /// 2. Longest matching prefix pattern ending in `/*` (e.g.
2411 /// `"team/*"` matches `"team/eng"` and `"team/eng/inner"`).
2412 /// 3. Bare `"*"` wildcard.
2413 /// 4. The struct-level `default_ttl_secs` / `archive_grace_secs`.
2414 /// 5. The compiled defaults
2415 /// ([`DEFAULT_TRANSCRIPT_TTL_SECS`] / [`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`]).
2416 ///
2417 /// Each field is resolved independently — a per-namespace override
2418 /// that only sets `default_ttl_secs` inherits the global
2419 /// `archive_grace_secs`. Non-positive values fall through to the
2420 /// next layer; positive values are clamped to
2421 /// `MAX_TRANSCRIPT_LIFECYCLE_SECS` so the resolved `Duration`
2422 /// addition can never overflow `chrono`.
2423 #[must_use]
2424 pub fn resolve(&self, namespace: &str) -> ResolvedTranscriptLifecycle {
2425 let ns_table = self.namespaces.as_ref();
2426
2427 // Walk the namespace overrides in precedence order, returning
2428 // the first that names the field. `None` means "fall through".
2429 let pick_ns = |field: fn(&TranscriptNamespaceConfig) -> Option<i64>| -> Option<i64> {
2430 let table = ns_table?;
2431 // 1. Exact literal match.
2432 if let Some(ns) = table.get(namespace) {
2433 if let Some(v) = field(ns) {
2434 return Some(v);
2435 }
2436 }
2437 // 2. Longest-prefix `prefix/*` match.
2438 let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
2439 .iter()
2440 .filter_map(|(k, v)| {
2441 let prefix = k.strip_suffix("/*")?;
2442 if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
2443 Some((prefix, v))
2444 } else {
2445 None
2446 }
2447 })
2448 .collect();
2449 prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
2450 for (_, ns) in &prefix_hits {
2451 if let Some(v) = field(ns) {
2452 return Some(v);
2453 }
2454 }
2455 // 3. Bare wildcard.
2456 if let Some(ns) = table.get("*") {
2457 if let Some(v) = field(ns) {
2458 return Some(v);
2459 }
2460 }
2461 None
2462 };
2463
2464 let clamp = |v: i64, fallback: i64| -> i64 {
2465 if v <= 0 {
2466 fallback
2467 } else {
2468 v.min(MAX_TRANSCRIPT_LIFECYCLE_SECS)
2469 }
2470 };
2471
2472 let ttl = pick_ns(|n| n.default_ttl_secs)
2473 .or(self.default_ttl_secs)
2474 .map_or(DEFAULT_TRANSCRIPT_TTL_SECS, |v| {
2475 clamp(v, DEFAULT_TRANSCRIPT_TTL_SECS)
2476 });
2477 let grace = pick_ns(|n| n.archive_grace_secs)
2478 .or(self.archive_grace_secs)
2479 .map_or(DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS, |v| {
2480 clamp(v, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS)
2481 });
2482
2483 ResolvedTranscriptLifecycle {
2484 default_ttl_secs: ttl,
2485 archive_grace_secs: grace,
2486 }
2487 }
2488
2489 /// v0.7 I5 — resolve the `auto_extract` opt-in for `namespace`.
2490 ///
2491 /// Same precedence walk as [`Self::resolve`] but folds the
2492 /// boolean field of [`TranscriptNamespaceConfig::auto_extract`]:
2493 ///
2494 /// 1. Exact match.
2495 /// 2. Longest-prefix `prefix/*` match.
2496 /// 3. Bare wildcard `"*"`.
2497 /// 4. `false` (default off — matches the "every reference hook
2498 /// ships off-by-default" lesson from G10/G11).
2499 ///
2500 /// The R5 reference extractor (`tools/transcript-extractor/`)
2501 /// reads this flag at the namespace gate before doing any LLM
2502 /// work, so a namespace that hasn't opted in pays the cost of
2503 /// one HashMap lookup per `pre_store` fire and nothing more.
2504 #[must_use]
2505 pub fn auto_extract_for(&self, namespace: &str) -> bool {
2506 let Some(table) = self.namespaces.as_ref() else {
2507 return false;
2508 };
2509 // 1. Exact literal match.
2510 if let Some(ns) = table.get(namespace) {
2511 if let Some(v) = ns.auto_extract {
2512 return v;
2513 }
2514 }
2515 // 2. Longest-prefix `prefix/*` match.
2516 let mut prefix_hits: Vec<(&str, &TranscriptNamespaceConfig)> = table
2517 .iter()
2518 .filter_map(|(k, v)| {
2519 let prefix = k.strip_suffix("/*")?;
2520 if namespace == prefix || namespace.starts_with(&format!("{prefix}/")) {
2521 Some((prefix, v))
2522 } else {
2523 None
2524 }
2525 })
2526 .collect();
2527 prefix_hits.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
2528 for (_, ns) in &prefix_hits {
2529 if let Some(v) = ns.auto_extract {
2530 return v;
2531 }
2532 }
2533 // 3. Bare wildcard.
2534 if let Some(ns) = table.get("*") {
2535 if let Some(v) = ns.auto_extract {
2536 return v;
2537 }
2538 }
2539 // 4. Default off.
2540 false
2541 }
2542}
2543
2544// ---------------------------------------------------------------------------
2545// Recall scoring (time-decay half-life) — v0.6.0.0
2546// ---------------------------------------------------------------------------
2547
2548/// Per-tier half-life (days) overrides loaded from `[scoring]` section of
2549/// `config.toml`.
2550///
2551/// The half-life is the number of days it takes for a memory's recall score
2552/// to drop to 50% of its undecayed value. Shorter half-lives prioritize fresh
2553/// memories; longer half-lives give older memories more weight. Defaults are
2554/// chosen so each tier's decay curve matches its retention expectations:
2555/// `short` memories decay quickly (7 d), `mid` moderately (30 d), `long`
2556/// slowly (365 d).
2557///
2558/// Setting `legacy_scoring = true` disables the decay multiplier entirely,
2559/// restoring the pre-v0.6.0.0 blended-score behavior for A/B comparison or
2560/// if a recall-quality regression is reported.
2561#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2562pub struct RecallScoringConfig {
2563 /// Half-life for `short`-tier memories, in days (default 7).
2564 pub half_life_days_short: Option<f64>,
2565 /// Half-life for `mid`-tier memories, in days (default 30).
2566 pub half_life_days_mid: Option<f64>,
2567 /// Half-life for `long`-tier memories, in days (default 365).
2568 pub half_life_days_long: Option<f64>,
2569 /// When true, skip the decay multiplier entirely. Default false.
2570 #[serde(default)]
2571 pub legacy_scoring: bool,
2572}
2573
2574/// Resolved scoring values after merging config overrides with compiled
2575/// defaults. Half-lives are clamped to the range `[0.1, 36_500.0]` days
2576/// (≈100 years) to keep the decay math well-behaved.
2577#[derive(Debug, Clone, Copy)]
2578pub struct ResolvedScoring {
2579 pub half_life_days_short: f64,
2580 pub half_life_days_mid: f64,
2581 pub half_life_days_long: f64,
2582 pub legacy_scoring: bool,
2583}
2584
2585impl Default for ResolvedScoring {
2586 fn default() -> Self {
2587 Self {
2588 half_life_days_short: 7.0,
2589 half_life_days_mid: 30.0,
2590 half_life_days_long: 365.0,
2591 legacy_scoring: false,
2592 }
2593 }
2594}
2595
2596impl ResolvedScoring {
2597 const MIN_HALF_LIFE: f64 = 0.1;
2598 const MAX_HALF_LIFE: f64 = 36_500.0;
2599
2600 /// Build from optional config overrides, falling back to compiled
2601 /// defaults. Out-of-range values are silently clamped.
2602 pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
2603 let defaults = Self::default();
2604 let Some(c) = cfg else {
2605 return defaults;
2606 };
2607 let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
2608 Self {
2609 half_life_days_short: c
2610 .half_life_days_short
2611 .map_or(defaults.half_life_days_short, clamp),
2612 half_life_days_mid: c
2613 .half_life_days_mid
2614 .map_or(defaults.half_life_days_mid, clamp),
2615 half_life_days_long: c
2616 .half_life_days_long
2617 .map_or(defaults.half_life_days_long, clamp),
2618 legacy_scoring: c.legacy_scoring,
2619 }
2620 }
2621
2622 /// Half-life in days for a given tier.
2623 pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
2624 match tier {
2625 Tier::Short => self.half_life_days_short,
2626 Tier::Mid => self.half_life_days_mid,
2627 Tier::Long => self.half_life_days_long,
2628 }
2629 }
2630
2631 /// Compute the decay multiplier `exp(-ln(2) * age_days / half_life)`
2632 /// for a memory of the given tier and age. Returns `1.0` when
2633 /// `legacy_scoring` is true (no decay) or when `age_days` is non-positive
2634 /// (future timestamps, clock skew, or new memories).
2635 #[must_use]
2636 pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
2637 if self.legacy_scoring || age_days <= 0.0 {
2638 return 1.0;
2639 }
2640 let half_life = self.half_life_for_tier(tier);
2641 (-std::f64::consts::LN_2 * age_days / half_life).exp()
2642 }
2643}
2644
2645// ---------------------------------------------------------------------------
2646// Persistent config file (~/.config/ai-memory/config.toml)
2647// ---------------------------------------------------------------------------
2648
2649const CONFIG_DIR: &str = ".config/ai-memory";
2650const CONFIG_FILE: &str = "config.toml";
2651
2652/// Persistent configuration loaded from `~/.config/ai-memory/config.toml`.
2653///
2654/// All fields are optional — CLI flags override file values, which override
2655/// compiled defaults.
2656#[derive(Clone, Default, Serialize, Deserialize)]
2657pub struct AppConfig {
2658 /// Feature tier: keyword, semantic, smart, autonomous
2659 pub tier: Option<String>,
2660 /// Path to the `SQLite` database file
2661 pub db: Option<String>,
2662 /// Ollama base URL for LLM generation (default: <http://localhost:11434>)
2663 ///
2664 /// DOC-6 (FX-C4-batch2, 2026-05-26): legacy flat field, slated
2665 /// for removal in v0.8.0. Use the sectioned `[llm].base_url` /
2666 /// `[embeddings].url` shape from #1146 instead. Run
2667 /// `ai-memory config migrate` to rewrite legacy configs.
2668 #[deprecated(
2669 since = "0.7.0",
2670 note = "use the sectioned `[llm].base_url` / `[embeddings].url` (#1146); slated for removal in v0.8.0"
2671 )]
2672 pub ollama_url: Option<String>,
2673 /// Separate URL for embedding model (defaults to `ollama_url` if unset)
2674 ///
2675 /// DOC-6: legacy; use `[embeddings].url`.
2676 #[deprecated(
2677 since = "0.7.0",
2678 note = "use `[embeddings].url` (#1146); slated for removal in v0.8.0"
2679 )]
2680 pub embed_url: Option<String>,
2681 /// Embedding model override: `mini_lm_l6_v2` or `nomic_embed_v15`
2682 ///
2683 /// DOC-6: legacy; use `[embeddings].model`.
2684 #[deprecated(
2685 since = "0.7.0",
2686 note = "use `[embeddings].model` (#1146); slated for removal in v0.8.0"
2687 )]
2688 pub embedding_model: Option<String>,
2689 /// LLM model override (Ollama tag, e.g. "gemma4:e2b")
2690 ///
2691 /// DOC-6: legacy; use `[llm].model`.
2692 #[deprecated(
2693 since = "0.7.0",
2694 note = "use `[llm].model` (#1146); slated for removal in v0.8.0"
2695 )]
2696 pub llm_model: Option<String>,
2697 /// Dedicated model for auto_tag (and other short-structured LLM calls).
2698 /// Defaults to `gemma3:4b` (fast, deterministic, ~0.7s p50 vs 15s for
2699 /// thinking-mode Gemma 4). Falls back to `llm_model` if unset.
2700 /// See L15 patch (2026-05-11) for rationale.
2701 ///
2702 /// DOC-6: legacy; use `[llm.auto_tag].model`.
2703 #[deprecated(
2704 since = "0.7.0",
2705 note = "use `[llm.auto_tag].model` (#1146); slated for removal in v0.8.0"
2706 )]
2707 pub auto_tag_model: Option<String>,
2708 /// Enable cross-encoder reranking (true/false)
2709 ///
2710 /// DOC-6: legacy; use `[reranker].enabled`.
2711 #[deprecated(
2712 since = "0.7.0",
2713 note = "use `[reranker].enabled` (#1146); slated for removal in v0.8.0"
2714 )]
2715 pub cross_encoder: Option<bool>,
2716 /// Default namespace for new memories
2717 ///
2718 /// DOC-6: legacy; use `[storage].default_namespace`.
2719 #[deprecated(
2720 since = "0.7.0",
2721 note = "use `[storage].default_namespace` (#1146); slated for removal in v0.8.0"
2722 )]
2723 pub default_namespace: Option<String>,
2724 /// Maximum memory budget in MB (used for auto tier selection)
2725 ///
2726 /// DOC-6: legacy; the auto-tier path now resolves via the
2727 /// sectioned `[storage]` block.
2728 #[deprecated(
2729 since = "0.7.0",
2730 note = "auto-tier resolution now resolves via the sectioned [storage] block (#1146); slated for removal in v0.8.0"
2731 )]
2732 pub max_memory_mb: Option<usize>,
2733 /// Per-tier TTL overrides
2734 pub ttl: Option<TtlConfig>,
2735 /// Archive memories before GC deletion (default: true)
2736 ///
2737 /// DOC-6: legacy; use `[storage].archive_on_gc`.
2738 #[deprecated(
2739 since = "0.7.0",
2740 note = "use `[storage].archive_on_gc` (#1146); slated for removal in v0.8.0"
2741 )]
2742 pub archive_on_gc: Option<bool>,
2743 /// Optional API key for HTTP API authentication.
2744 ///
2745 /// #1262 — `skip_serializing` prevents the secret from being
2746 /// echoed back through any `serde_json::to_string(&AppConfig)`
2747 /// path (capabilities overlays, debug dumps, audit traces).
2748 /// #1454 — the manual `Debug` impl on `AppConfig` (just below the
2749 /// struct) renders this field as `<redacted>`, so a `{:?}` of the
2750 /// config never leaks the secret either (`skip_serializing` only
2751 /// guards the serde JSON path, not `Debug`).
2752 /// #1258 — [`AppConfig::zeroize_secrets`] (a free helper method,
2753 /// NOT a blanket `Drop` impl) zeroizes this buffer; callers invoke
2754 /// it immediately before scope-exit. A blanket `Drop` is
2755 /// deliberately avoided so the `..AppConfig::default()`
2756 /// struct-update spread used across ~20 test sites still compiles.
2757 #[serde(default, skip_serializing)]
2758 pub api_key: Option<String>,
2759 /// Maximum archive age in days for automatic purge during GC (default: disabled)
2760 ///
2761 /// DOC-6: legacy; the archive purge knob resolves via the
2762 /// sectioned `[storage]` block at v0.7.x.
2763 #[deprecated(
2764 since = "0.7.0",
2765 note = "archive purge resolution moves under the sectioned [storage] block (#1146); slated for removal in v0.8.0"
2766 )]
2767 pub archive_max_days: Option<i64>,
2768 /// Identity-resolution overrides (Task 1.2 follow-up #198).
2769 pub identity: Option<IdentityConfig>,
2770 /// Recall scoring — per-tier half-life for time-decay, and `legacy_scoring`
2771 /// kill switch (v0.6.0.0).
2772 pub scoring: Option<RecallScoringConfig>,
2773 /// v0.6.0.0: when true, fire LLM autonomy hooks (`auto_tag` +
2774 /// `detect_contradiction`) synchronously on every successful
2775 /// `memory_store`. Off by default — the hook blocks store latency
2776 /// behind an Ollama round-trip. `AI_MEMORY_AUTONOMOUS_HOOKS=1`
2777 /// env var overrides the config file.
2778 pub autonomous_hooks: Option<bool>,
2779 /// v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
2780 /// Default-OFF for privacy; opt-in turns on the rolling file
2781 /// appender that captures every `tracing::*` call site to disk.
2782 pub logging: Option<LoggingConfig>,
2783 /// v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF
2784 /// for privacy; opt-in emits a hash-chained, tamper-evident JSON
2785 /// log of every memory mutation suitable for SIEM ingestion and
2786 /// SOC2 / HIPAA / GDPR / FedRAMP compliance evidence.
2787 pub audit: Option<AuditConfig>,
2788 /// v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy
2789 /// kill-switch. Default-ON (existing users see no behavior change);
2790 /// `[boot] enabled = false` silences boot entirely (empty stdout +
2791 /// empty stderr, exit 0) for privacy-sensitive hosts where memory
2792 /// titles must not enter CI logs. `[boot] redact_titles = true`
2793 /// keeps the manifest header but replaces row titles with
2794 /// `<redacted>` for compliance contexts that need the audit-trail
2795 /// signal of "boot ran with N memories" without exposing subjects.
2796 pub boot: Option<BootConfig>,
2797 /// v0.6.4 — MCP server tunables. Today this only carries `profile`
2798 /// (the named tool surface). Future v0.6.4 phases add the
2799 /// `[mcp.allowlist]` per-agent capability table (Track D —
2800 /// v0.6.4-008).
2801 pub mcp: Option<McpConfig>,
2802 /// v0.7.0 K3 — `[permissions]` block. Drives the gate's enforcement
2803 /// posture (`enforce` / `advisory` / `off`). When unset, the
2804 /// compiled default in [`PermissionsConfig::default`] applies
2805 /// (`advisory` — preserves the v0.6.x honest-disclosure posture
2806 /// where governance metadata was recorded but not blocked at the
2807 /// gate). New installs that want the strict gate set
2808 /// `[permissions] mode = "enforce"` explicitly.
2809 pub permissions: Option<PermissionsConfig>,
2810 /// v0.7.0 I3 — `[transcripts]` block. Per-namespace TTL and
2811 /// archive-grace overrides for the transcript lifecycle sweeper.
2812 /// Unset → compiled defaults apply globally
2813 /// ([`DEFAULT_TRANSCRIPT_TTL_SECS`] / [`DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS`]).
2814 pub transcripts: Option<TranscriptsConfig>,
2815 /// v0.7.0 K7 — `[hooks]` block. Currently carries the
2816 /// `[hooks.subscription] hmac_secret` server-wide override that
2817 /// signs every outgoing webhook payload regardless of whether the
2818 /// individual subscription supplied a per-subscription secret.
2819 /// When unset, only per-subscription secrets are used (legacy
2820 /// pre-K7 behaviour).
2821 pub hooks: Option<HooksConfig>,
2822 /// v0.7.0 H11 (#628 blocker) — `[subscriptions]` block. Carries
2823 /// the `allow_loopback_webhooks` opt-in that re-enables loopback
2824 /// webhook URLs (`127.0.0.1`, `localhost`, `[::1]`). Default-OFF
2825 /// closes an authenticated SSRF gadget against local services
2826 /// (Postgres on 5432, the hooks daemon, etc.). Operators who need
2827 /// loopback for testing must set this explicitly.
2828 pub subscriptions: Option<SubscriptionsConfig>,
2829 /// v0.7.0 H5 (round-2) — `[verify]` block. Today exposes one
2830 /// knob: `require_nonce` (default `false`). When `true`, every
2831 /// `POST /api/v1/links/verify` request MUST include a
2832 /// `verification_nonce` (UUID v4 expected); missing or replayed
2833 /// nonces are rejected with 409 Conflict. Default-OFF preserves
2834 /// the v0.6.x verify-anytime semantics for unmigrated clients.
2835 pub verify: Option<VerifyConfig>,
2836 /// v0.7.0 M4 — connection-level `statement_timeout` (in seconds)
2837 /// applied via an `after_connect` hook to every postgres
2838 /// connection in the pool. Bounds runaway queries — a pathological
2839 /// `pg_sleep(60)` or an unbounded scan can otherwise wedge a
2840 /// connection forever. Defaults to 30s when unset; set to 0 to
2841 /// disable the limit (matches the postgres `SET` semantics).
2842 /// Operators only need to touch this when the workload requires
2843 /// long-running maintenance queries from the daemon itself.
2844 pub postgres_statement_timeout_secs: Option<u64>,
2845 /// v0.7.0 (a) — connection-pool ceiling (sqlx `max_connections`)
2846 /// for the postgres backend. `None` selects the compiled
2847 /// `DEFAULT_MAX_CONNECTIONS`. Operators tune this per module/daemon
2848 /// without a recompile via `AI_MEMORY_PG_POOL_MAX`. Resolved by
2849 /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2850 /// to the default.
2851 pub postgres_pool_max_connections: Option<u32>,
2852 /// v0.7.0 (a) — connection-pool floor of always-open warm
2853 /// connections (sqlx `min_connections`). `None` selects the
2854 /// compiled `DEFAULT_MIN_CONNECTIONS`. Operator knob:
2855 /// `AI_MEMORY_PG_POOL_MIN`. Resolved by
2856 /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2857 /// to the default.
2858 pub postgres_pool_min_connections: Option<u32>,
2859 /// v0.7.0 (a) — how long a pool `acquire()` waits for a free
2860 /// connection before erroring (sqlx `acquire_timeout`), in whole
2861 /// seconds. `None` selects the compiled default derived from
2862 /// `DEFAULT_ACQUIRE_TIMEOUT`. Operator knob:
2863 /// `AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS`. Resolved by
2864 /// [`AppConfig::resolve_pg_pool`]; non-positive values fall through
2865 /// to the default.
2866 pub postgres_acquire_timeout_secs: Option<u64>,
2867 /// v0.7.0 H7 (round-2) — per-HTTP-request wall-clock timeout in
2868 /// seconds. Applied as a middleware to every axum route in
2869 /// [`crate::build_router`] so a slow-POST (slowloris-style)
2870 /// attacker cannot keep a handler scope alive indefinitely.
2871 /// `None` selects the compiled default of 60 seconds; operators
2872 /// who need a different ceiling set
2873 /// `request_timeout_secs = <secs>` in `config.toml`.
2874 pub request_timeout_secs: Option<u64>,
2875 /// v0.7.0 H8 (round-2) — per-LLM-call wall-clock timeout in
2876 /// seconds. Wraps every `spawn_blocking` invocation of an Ollama
2877 /// call (`auto_tag`, `expand_query`, `summarize_memories`, ...)
2878 /// in `tokio::time::timeout`. `None` selects the compiled
2879 /// default of 30 seconds; on timeout the call falls back to the
2880 /// LLM-absent path (already exercised by L5/L7).
2881 pub llm_call_timeout_secs: Option<u64>,
2882 /// v0.7.0 (issue #318) — when set, the MCP stdio server forwards
2883 /// every write tool (`memory_store`, `memory_link`, `memory_delete`)
2884 /// to this HTTP endpoint (typically the local `ai-memory serve`
2885 /// daemon at `http://localhost:9077`) instead of writing to SQLite
2886 /// directly. The HTTP daemon then runs the existing
2887 /// `broadcast_store_quorum` / `broadcast_link_quorum` / etc. fanout,
2888 /// closing the gap surfaced by a2a-gate v0.6.0 r6 where MCP-stdio
2889 /// writes replicated locally but never reached the federation mesh.
2890 ///
2891 /// Unset (the default) keeps the legacy direct-SQLite path so
2892 /// single-node MCP deployments without a federation daemon behave
2893 /// exactly as before. The forwarder uses `reqwest::blocking` and
2894 /// surfaces HTTP errors as MCP error strings; on transport failure
2895 /// the response carries the underlying error so operators can
2896 /// distinguish "fanout daemon not running" from "quorum not met".
2897 pub mcp_federation_forward_url: Option<String>,
2898 /// v0.7.0 (issue #518) — `[agents.defaults]` block. Carries the
2899 /// `recall_scope` defaults spliced into `memory_recall` /
2900 /// `GET /api/v1/recall` / `ai-memory recall` requests that pass
2901 /// `session_default=true` (or `--session-default` on the CLI) and
2902 /// omit one or more filter fields. Closes the OpenClaw v0.6.3.1
2903 /// "what were you working on?" recovery gap — agents picking up a
2904 /// new session no longer need to remember to splice the canonical
2905 /// namespace + recency filters on every cross-session recall.
2906 ///
2907 /// `None` (the default) preserves single-tenant deployments and
2908 /// existing recall semantics exactly as-is. The splice happens in
2909 /// the handler before the storage call; explicit args always win
2910 /// over the defaults.
2911 pub agents: Option<AgentsConfig>,
2912 /// v0.7.0 SEC-2 (Cluster D, issue #767) — `[governance]` block.
2913 /// Today exposes one knob: `require_operator_pubkey` (default
2914 /// `false`). When `true`, daemon `serve` startup REFUSES to boot
2915 /// if the `governance_rules` table contains any `enabled = 1`
2916 /// rows AND no operator pubkey is resolved (env var or
2917 /// `~/.config/ai-memory/operator.key.pub`). Closes the
2918 /// fail-OPEN gap where a SQL-write gadget could install
2919 /// `enabled = 1` rules that the pre-L1-6 loader would honour
2920 /// without signature check. Default `false` preserves the
2921 /// pre-cluster-D contract for the install-script deploy where
2922 /// no operator pubkey is yet on disk.
2923 pub governance: Option<GovernanceConfig>,
2924 /// v0.7.0 Cluster G (#767) — `[confidence]` block. Carries the
2925 /// retention window for `confidence_shadow_observations` consumed
2926 /// by the periodic GC sweep (`shadow_retention_days`, default 30).
2927 /// Unset → the compiled default applies. Closes PERF-4: the v0.7.0
2928 /// Form 5 closeout (#758) shipped the shadow-mode table but did
2929 /// NOT ship retention, so a long-running shadow-enabled deployment
2930 /// would see unbounded growth.
2931 pub confidence: Option<ConfidenceConfig>,
2932 /// v0.7.0 SHIP cluster (#946 / #957 / #960 / #961, 2026-05-20) —
2933 /// `[admin]` top-level block. Carries the operator-configured
2934 /// allowlist of `agent_ids` whose authenticated HTTP requests
2935 /// are treated as admin-class callers (full cross-tenant
2936 /// visibility for endpoints that must observe corpus-scale
2937 /// metadata: `GET /api/v1/export`, `GET /api/v1/agents`,
2938 /// `GET /api/v1/stats`, the `POST /api/v1/quota/status` list
2939 /// path). `None` (the default) closes those endpoints to all
2940 /// non-admin callers — the safe-by-default posture per CLAUDE.md
2941 /// `pm-v3`. See [`AdminConfig`] for the full role-gate semantics.
2942 pub admin: Option<AdminConfig>,
2943
2944 // ------------------------------------------------------------------
2945 // v0.7.x enterprise configuration sections (issue #1146).
2946 //
2947 // These four sectioned blocks (`[llm]` / `[embeddings]` /
2948 // `[reranker]` / `[storage]`) consolidate the previously-flat
2949 // LLM / embedder / reranker / storage knobs into named tables with
2950 // a uniform canonical resolver. Legacy flat fields above
2951 // (`llm_model`, `ollama_url`, `embed_url`, `embedding_model`,
2952 // `cross_encoder`, `default_namespace`, `archive_on_gc`,
2953 // `archive_max_days`, `max_memory_mb`) continue to parse and feed
2954 // the resolver's legacy arm with a one-shot deprecation WARN until
2955 // v0.8.0 removes them.
2956 //
2957 // The `schema_version` field carries the explicit shape version.
2958 // Absent / `1` selects the legacy parse path; `>= 2` selects the
2959 // sectioned parse path and warns when legacy fields are also
2960 // present (so an operator who hand-edited the file knows the
2961 // legacy fields are dead weight).
2962 // ------------------------------------------------------------------
2963 /// v0.7.x (#1146) — explicit configuration schema version. `None`
2964 /// or `1` selects the v0.6.x flat-field parse path; `2` selects
2965 /// the sectioned parse path (`[llm]`, `[embeddings]`, `[reranker]`,
2966 /// `[storage]`) and emits a WARN if any legacy flat field is also
2967 /// present. Future bumps (`3`, `4`, …) introduce additional schema
2968 /// transitions and are gated through `ai-memory config migrate`.
2969 pub schema_version: Option<u32>,
2970
2971 /// v0.7.x (#1146) — `[llm]` sectioned LLM configuration. Carries
2972 /// the canonical backend / model / base_url / api_key references
2973 /// consumed by every LLM-init surface (MCP stdio, HTTP daemon,
2974 /// `ai-memory atomise`, `ai-memory curator`, embed-client
2975 /// disambiguator, the boot banner). Resolved via
2976 /// [`AppConfig::resolve_llm`]; the resolver applies the uniform
2977 /// precedence ladder (CLI flag > `AI_MEMORY_LLM_*` env > `[llm]`
2978 /// section > legacy flat fields > compiled default).
2979 ///
2980 /// Includes an optional `[llm.auto_tag]` sub-table for the fast
2981 /// structured-output sibling that handles `auto_tag`, query
2982 /// expansion, and contradiction detection — see [`LlmSection`].
2983 pub llm: Option<LlmSection>,
2984
2985 /// v0.7.x (#1146) — `[embeddings]` sectioned embedding-model
2986 /// configuration. Consumed by the embedder bootstrap in
2987 /// `daemon_runtime` and the MCP embed-client fallback path.
2988 /// Resolved via [`AppConfig::resolve_embeddings`].
2989 pub embeddings: Option<EmbeddingsSection>,
2990
2991 /// v0.7.x (#1146) — `[reranker]` sectioned cross-encoder
2992 /// configuration. Folds the legacy `cross_encoder = bool` knob
2993 /// into a `{ enabled, model }` table with explicit model
2994 /// selection. Resolved via [`AppConfig::resolve_reranker`].
2995 pub reranker: Option<RerankerSection>,
2996
2997 /// #1671/n15 (v0.7.1) — `[curator]` per-namespace curator config.
2998 /// Carries the per-namespace `reflection_pass.enabled` gate that
2999 /// `curator --reflect --all-namespaces` consults (#1671 — without it
3000 /// `--all-namespaces` reflected nothing) and the per-namespace
3001 /// `confidence_decay_half_life_days` override the confidence-decay
3002 /// sweep consults (n15). Resolved via
3003 /// [`AppConfig::reflection_namespace_enabled`] and
3004 /// [`AppConfig::confidence_decay_half_life_for`].
3005 pub curator: Option<CuratorSection>,
3006
3007 /// v0.7.x (#1146) — `[storage]` sectioned storage configuration.
3008 /// Carries `default_namespace`, `archive_on_gc`, `archive_max_days`,
3009 /// `max_memory_mb` (folded from the previously-flat top-level
3010 /// fields). The `db` path stays top-level per the I4 carve-out in
3011 /// #1146 (path expansion semantics pinned by #507).
3012 pub storage: Option<StorageSection>,
3013
3014 /// v0.7.x — `[limits]` sectioned operator-tunable capacity limits.
3015 /// Carries the per-(agent, namespace) daily memory-write quota, the
3016 /// lifetime storage cap, the daily link-creation quota, and the
3017 /// list/bulk request page-size cap. Resolved via
3018 /// [`AppConfig::resolve_limits`]; the resolver applies the uniform
3019 /// precedence ladder (`AI_MEMORY_MAX_*` env > `[limits]` section >
3020 /// compiled default). Defaults are deliberately generous so the
3021 /// substrate is invisible to small-scale operators; operators with
3022 /// high event-rate workloads raise them per-deployment without
3023 /// recompiling. See [`LimitsSection`].
3024 pub limits: Option<LimitsSection>,
3025}
3026
3027// #1454 (SEC, LOW) — manual `Debug` so the `api_key` secret renders as
3028// `<redacted>` instead of leaking through a `{:?}` of the whole config
3029// (mirrors the `ResolvedLlm` redaction model further down this file).
3030// Every other field is rendered verbatim. KEEP IN SYNC: a new field on
3031// `AppConfig` must be mirrored here or it silently drops from Debug.
3032#[allow(deprecated)] // legacy flat fields are deprecated but still debugged
3033impl std::fmt::Debug for AppConfig {
3034 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3035 f.debug_struct("AppConfig")
3036 .field("tier", &self.tier)
3037 .field("db", &self.db)
3038 .field(config_keys::OLLAMA_URL, &self.ollama_url)
3039 .field("embed_url", &self.embed_url)
3040 .field(config_keys::EMBEDDING_MODEL, &self.embedding_model)
3041 .field("llm_model", &self.llm_model)
3042 .field(config_keys::AUTO_TAG_MODEL, &self.auto_tag_model)
3043 .field(config_keys::CROSS_ENCODER, &self.cross_encoder)
3044 .field(config_keys::DEFAULT_NAMESPACE, &self.default_namespace)
3045 .field(config_keys::MAX_MEMORY_MB, &self.max_memory_mb)
3046 .field("ttl", &self.ttl)
3047 .field(config_keys::ARCHIVE_ON_GC, &self.archive_on_gc)
3048 .field(
3049 "api_key",
3050 &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3051 )
3052 .field(config_keys::ARCHIVE_MAX_DAYS, &self.archive_max_days)
3053 .field("identity", &self.identity)
3054 .field("scoring", &self.scoring)
3055 .field("autonomous_hooks", &self.autonomous_hooks)
3056 .field("logging", &self.logging)
3057 .field("audit", &self.audit)
3058 .field("boot", &self.boot)
3059 .field("mcp", &self.mcp)
3060 .field("permissions", &self.permissions)
3061 .field("transcripts", &self.transcripts)
3062 .field("hooks", &self.hooks)
3063 .field("subscriptions", &self.subscriptions)
3064 .field("verify", &self.verify)
3065 .field(
3066 "postgres_statement_timeout_secs",
3067 &self.postgres_statement_timeout_secs,
3068 )
3069 .field(
3070 "postgres_pool_max_connections",
3071 &self.postgres_pool_max_connections,
3072 )
3073 .field(
3074 "postgres_pool_min_connections",
3075 &self.postgres_pool_min_connections,
3076 )
3077 .field(
3078 "postgres_acquire_timeout_secs",
3079 &self.postgres_acquire_timeout_secs,
3080 )
3081 .field("request_timeout_secs", &self.request_timeout_secs)
3082 .field("llm_call_timeout_secs", &self.llm_call_timeout_secs)
3083 .field(
3084 "mcp_federation_forward_url",
3085 &self.mcp_federation_forward_url,
3086 )
3087 .field("agents", &self.agents)
3088 .field("governance", &self.governance)
3089 .field("confidence", &self.confidence)
3090 .field("admin", &self.admin)
3091 .field("schema_version", &self.schema_version)
3092 .field("llm", &self.llm)
3093 .field(config_keys::SECTION_EMBEDDINGS, &self.embeddings)
3094 .field("reranker", &self.reranker)
3095 .field("storage", &self.storage)
3096 .field("limits", &self.limits)
3097 .finish()
3098 }
3099}
3100
3101impl AppConfig {
3102 /// #1258 — manually zeroize the `api_key` buffer. Callers that hold
3103 /// the only owner of an `AppConfig` and are about to drop it
3104 /// invoke this immediately before scope-exit so the secret bytes
3105 /// do not linger on the heap. The free-standing helper (instead of
3106 /// a blanket `Drop` impl on `AppConfig`) preserves the
3107 /// `..AppConfig::default()` struct-update syntax used by ~20
3108 /// existing test sites; adding a blanket `Drop` would forbid the
3109 /// move-by-spread pattern Rust requires for `Drop` types.
3110 pub fn zeroize_secrets(&mut self) {
3111 use zeroize::Zeroize;
3112 if let Some(key) = self.api_key.as_mut() {
3113 key.zeroize();
3114 }
3115 }
3116}
3117
3118/// v0.7.0 SEC-2 (Cluster D, issue #767) — `[governance]` top-level
3119/// block. Today exposes a single fail-closed knob; future governance
3120/// knobs (e.g., signature-rotation policy timestamps, per-rule
3121/// override timeouts) can stack here.
3122///
3123/// Wire format:
3124/// ```toml
3125/// [governance]
3126/// require_operator_pubkey = true
3127/// ```
3128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3129pub struct GovernanceConfig {
3130 /// SEC-2 fail-closed switch. When `true`, the daemon refuses to
3131 /// start if the `governance_rules` table contains any
3132 /// `enabled = 1` row AND no operator pubkey is resolved. Default
3133 /// `false` preserves the pre-cluster-D contract that the
3134 /// substrate stays in pre-L1-6 mode (every enabled rule passes
3135 /// through) until the operator activates L1-6 by placing the
3136 /// pubkey on disk or setting `AI_MEMORY_OPERATOR_PUBKEY`.
3137 ///
3138 /// Operators running the install-script default deploy who want
3139 /// strict enforcement BEFORE the operator pubkey lands set this
3140 /// to `true` — the daemon will then surface a clear error
3141 /// message naming the missing pubkey path.
3142 #[serde(default)]
3143 pub require_operator_pubkey: bool,
3144}
3145
3146/// v0.7.0 SHIP cluster (#946 / #957 / #960 / #961, 2026-05-20) —
3147/// `[admin]` top-level block. The operator-configured allowlist of
3148/// `agent_ids` whose authenticated HTTP requests are treated as
3149/// admin-class callers, granting full cross-tenant visibility on
3150/// endpoints whose payloads necessarily expose corpus-scale
3151/// metadata (`GET /api/v1/export`, `GET /api/v1/agents`,
3152/// `GET /api/v1/stats`, the `POST /api/v1/quota/status` list path).
3153///
3154/// Wire format:
3155/// ```toml
3156/// [admin]
3157/// agent_ids = ["ops:admin", "ai:claude@workstation"]
3158/// ```
3159///
3160/// **Default-closed.** When the block is absent, the allowlist is
3161/// empty and every admin-class endpoint returns `403 Forbidden` for
3162/// every caller. Operators MUST set `[admin].agent_ids = [...]`
3163/// explicitly to grant any caller admin privileges. This closes
3164/// the v0.7.0 SHIP-blocking cross-tenant exfiltration defects
3165/// (#946 / #957 / #960) where admin endpoints landed open by default
3166/// because the legacy `api_key_auth` middleware passes through when
3167/// no API key is configured.
3168///
3169/// **Caller resolution** uses the same primitive other handlers do
3170/// (`identity::resolve_http_agent_id` against `X-Agent-Id`). The
3171/// allowlist matches against the resolved caller string verbatim;
3172/// there is no glob / prefix support today (planned under #961 when
3173/// the operator surface grows beyond a static list).
3174///
3175/// **Not a substitute for authentication.** The role gate runs
3176/// AFTER `api_key_auth`. Deployments serving sensitive corpora
3177/// MUST set `api_key` so the bare-network surface requires the key
3178/// AND the role gate runs on top of it. The two layers compose:
3179/// `api_key_auth` answers "is the request authenticated?" and the
3180/// admin gate answers "is the authenticated caller an admin?".
3181#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3182pub struct AdminConfig {
3183 /// Explicit list of `agent_id` strings whose authenticated
3184 /// requests are treated as admin-class. Default `vec![]`
3185 /// (empty) means no caller is an admin — every admin-class
3186 /// endpoint returns 403.
3187 ///
3188 /// Each entry MUST match a caller's resolved `agent_id`
3189 /// verbatim. Validation: the SAL accepts the same NHI
3190 /// `agent_id` charset that
3191 /// [`crate::validate::validate_agent_id`] enforces (see the
3192 /// "Agent Identity (NHI)" section of CLAUDE.md). Entries that
3193 /// fail validation at boot are logged at `warn` and dropped
3194 /// from the in-memory allowlist; the daemon still starts so
3195 /// a single typo does not lock the operator out.
3196 #[serde(default)]
3197 pub agent_ids: Vec<String>,
3198}
3199
3200impl AdminConfig {
3201 /// Returns the validated subset of `agent_ids` — entries that
3202 /// pass [`crate::validate::validate_agent_id`]. Entries that
3203 /// fail validation are dropped (with a `warn` log) so a single
3204 /// typo in `config.toml` cannot lock the operator out.
3205 #[must_use]
3206 pub fn validated_agent_ids(&self) -> Vec<String> {
3207 let mut out = Vec::with_capacity(self.agent_ids.len());
3208 for id in &self.agent_ids {
3209 match crate::validate::validate_agent_id(id) {
3210 Ok(()) => out.push(id.clone()),
3211 Err(e) => {
3212 tracing::warn!("[admin] dropping invalid agent_id '{id}' from allowlist: {e}");
3213 }
3214 }
3215 }
3216 out
3217 }
3218}
3219
3220// ---------------------------------------------------------------------------
3221// v0.7.x enterprise configuration sections (issue #1146)
3222//
3223// `[llm]` / `[embeddings]` / `[reranker]` / `[storage]` consolidate
3224// previously-flat LLM / embedder / reranker / storage knobs into a
3225// uniform sectioned shape consumed by the canonical resolvers in
3226// `impl AppConfig`. See the issue for the full design rationale,
3227// migration plan, and acceptance criteria.
3228// ---------------------------------------------------------------------------
3229
3230/// v0.7.x (#1146) — `[llm]` sectioned LLM configuration.
3231///
3232/// Wire format:
3233/// ```toml
3234/// [llm]
3235/// backend = "xai" # ollama | openai | xai | anthropic | gemini | …
3236/// model = "grok-4.3" # vendor-specific identifier
3237/// base_url = "https://api.x.ai/v1" # optional; vendor-default if unset
3238/// api_key_env = "XAI_API_KEY" # env var name (mutually exclusive
3239/// # with api_key_file)
3240/// # api_key_file = "/etc/ai-memory/keys/xai.key" # mode 0400 enforced
3241///
3242/// [llm.auto_tag]
3243/// # Fast structured-output sibling (auto_tag, query expansion,
3244/// # contradiction detection). Fields fall back to parent [llm]
3245/// # field-by-field when unset; commonly only `model` is overridden.
3246/// model = "gemma3:4b"
3247/// ```
3248///
3249/// **Secret handling discipline.** Inline `api_key = "<literal>"` is
3250/// REJECTED at parse time — operators MUST use either
3251/// `api_key_env = "<ENV_VAR_NAME>"` (resolved at runtime) or
3252/// `api_key_file = "/path/to/key"` (mode 0400 enforced, override via
3253/// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1`). Both unset selects
3254/// the per-vendor-alias env-var fallback chain (see `src/llm.rs`
3255/// `alias_api_key_env_vars`).
3256///
3257/// **Precedence.** Resolved via [`AppConfig::resolve_llm`] through the
3258/// uniform precedence ladder: CLI flag > `AI_MEMORY_LLM_*` env vars >
3259/// `[llm]` section > legacy flat fields (`llm_model`, `ollama_url`) >
3260/// compiled default (warn-logged once on the resolver's `CompiledDefault`
3261/// arm).
3262#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3263pub struct LlmSection {
3264 /// Backend selector. One of: `ollama` (native `/api/chat` +
3265 /// `/api/embed`, no auth), `openai-compatible` (generic; requires
3266 /// explicit `base_url`), or an alias that pre-fills `base_url`
3267 /// (`openai`, `xai`, `anthropic`, `gemini`, `deepseek`, `kimi`,
3268 /// `qwen`, `mistral`, `groq`, `together`, `cerebras`, `openrouter`,
3269 /// `fireworks`, `lmstudio`). Unset = inherit legacy resolution
3270 /// (treated as `ollama`).
3271 pub backend: Option<String>,
3272
3273 /// Model identifier passed verbatim to the chat endpoint.
3274 /// Vendor-specific (e.g., `grok-4.3`, `gpt-5`, `claude-opus-4.7`).
3275 /// Unset = backend-specific default (see `OllamaClient::from_env`).
3276 pub model: Option<String>,
3277
3278 /// Optional base-URL override. Required when `backend =
3279 /// "openai-compatible"`; ignored otherwise (vendor-default
3280 /// applies). For `backend = "ollama"`, defaults to
3281 /// `http://localhost:11434`.
3282 pub base_url: Option<String>,
3283
3284 /// Name of the environment variable to read at runtime for the
3285 /// API-key Bearer auth secret. Mutually exclusive with
3286 /// `api_key_file`. Example: `api_key_env = "XAI_API_KEY"`. The
3287 /// `AI_MEMORY_LLM_API_KEY` process-env override (and the
3288 /// per-vendor fallback chain at `src/llm.rs`
3289 /// `alias_api_key_env_vars`) take precedence over this field per
3290 /// the uniform precedence ladder.
3291 pub api_key_env: Option<String>,
3292
3293 /// Path to a file whose first line is the API-key Bearer secret.
3294 /// Mutually exclusive with `api_key_env`. File must be `mode 0400`
3295 /// or stricter (overridable via
3296 /// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1` per #1055). Tilde
3297 /// expansion applies.
3298 pub api_key_file: Option<String>,
3299
3300 /// **REJECTED AT PARSE TIME.** Accepting the field name here lets
3301 /// the validator emit a clear "use api_key_env or api_key_file"
3302 /// error instead of serde's generic "unknown field". Operators
3303 /// inlining secrets in the config file see the security-rationale
3304 /// message at load time.
3305 #[serde(default)]
3306 pub api_key: Option<String>,
3307
3308 /// `[llm.auto_tag]` sub-table for the fast structured-output
3309 /// sibling (`auto_tag`, query expansion, contradiction detection).
3310 /// Unset = inherit every field from the parent [`LlmSection`].
3311 /// When set, only the explicitly-provided fields override; unset
3312 /// fields fall back to the parent.
3313 #[serde(default)]
3314 pub auto_tag: Option<LlmAutoTagSection>,
3315}
3316
3317// #1454 (SEC, LOW) — manual `Debug` redacts the parse-time-rejected
3318// inline `api_key` so a `{:?}` of an `LlmSection` never echoes a secret
3319// (mirrors `ResolvedLlm`). `api_key_env` / `api_key_file` are env-var
3320// names / file paths (config, not secret) and stay verbatim. KEEP IN
3321// SYNC: a new field must be mirrored here or it drops from Debug.
3322impl std::fmt::Debug for LlmSection {
3323 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3324 f.debug_struct("LlmSection")
3325 .field("backend", &self.backend)
3326 .field("model", &self.model)
3327 .field("base_url", &self.base_url)
3328 .field("api_key_env", &self.api_key_env)
3329 .field("api_key_file", &self.api_key_file)
3330 .field(
3331 "api_key",
3332 &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3333 )
3334 .field("auto_tag", &self.auto_tag)
3335 .finish()
3336 }
3337}
3338
3339/// v0.7.x (#1146) — `[llm.auto_tag]` sub-table. Fast structured-output
3340/// sibling of [`LlmSection`]. Fields fall back to the parent `[llm]`
3341/// section field-by-field when unset; commonly only `model` is
3342/// overridden to point at a faster model (default `gemma3:4b`,
3343/// ~0.7s p50 vs ~15s p50 for thinking-mode Gemma 4 per L15 patch).
3344#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3345pub struct LlmAutoTagSection {
3346 /// Backend override. Unset = inherit `[llm].backend`.
3347 pub backend: Option<String>,
3348 /// Model override. Unset = inherit `[llm].model`. Compiled default
3349 /// at the resolver level is `gemma3:4b` (L15 fast-structured-output
3350 /// model selection).
3351 pub model: Option<String>,
3352 /// Base-URL override. Unset = inherit `[llm].base_url`.
3353 pub base_url: Option<String>,
3354 /// Env-var-name override for the API key. Unset = inherit
3355 /// `[llm].api_key_env` (or `[llm].api_key_file`).
3356 pub api_key_env: Option<String>,
3357 /// File-path override for the API key. Unset = inherit
3358 /// `[llm].api_key_file` (or `[llm].api_key_env`).
3359 pub api_key_file: Option<String>,
3360}
3361
3362/// v0.7.x (#1146) — `[embeddings]` sectioned embedding-model
3363/// configuration.
3364///
3365/// Wire format:
3366/// ```toml
3367/// [embeddings]
3368/// backend = "openrouter" # ollama (default) or any
3369/// # #1067 API alias /
3370/// # openai-compatible (#1598)
3371/// base_url = "https://openrouter.ai/api/v1"
3372/// model = "google/gemini-embedding-2"
3373/// api_key_env = "OPENROUTER_API_KEY" # mutually exclusive with
3374/// # api_key_file = "/etc/ai-memory/keys/embed.key" # mode 0400 enforced
3375/// dim = 3072 # only needed for models
3376/// # outside the known-dims table
3377/// backfill_batch = 100 # 1-10000 (env override:
3378/// # AI_MEMORY_EMBED_BACKFILL_BATCH)
3379/// ```
3380#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3381pub struct EmbeddingsSection {
3382 /// Embedding backend. `ollama` (the default — local `/api/embed`
3383 /// wire shape) or, since #1598, any #1067 OpenAI-compatible alias
3384 /// (`openrouter`, `openai`, `gemini`, …) or the generic
3385 /// `openai-compatible` escape hatch for self-hosted endpoints
3386 /// (HF TEI, vLLM).
3387 pub backend: Option<String>,
3388
3389 /// Embedding endpoint URL. Defaults to `http://localhost:11434`
3390 /// when unset (ollama backend) or to the backend alias's default
3391 /// base URL (API backends, #1598). Synonym of [`Self::base_url`];
3392 /// `base_url` wins when both are set.
3393 pub url: Option<String>,
3394
3395 /// #1598 — embedding endpoint base URL. Synonym of [`Self::url`]
3396 /// (named to match `[llm].base_url`); when both are set,
3397 /// `base_url` wins.
3398 pub base_url: Option<String>,
3399
3400 /// Embedding model identifier. Legacy values `nomic_embed_v15`
3401 /// (alias for `nomic-embed-text-v1.5`) and `mini_lm_l6_v2` (alias
3402 /// for `sentence-transformers/all-MiniLM-L6-v2`) are honored at
3403 /// parse time.
3404 pub model: Option<String>,
3405
3406 /// #1598 — inline API-key literal. ALWAYS REJECTED at config load
3407 /// (mirrors `[llm].api_key`): config.toml is typically
3408 /// world-readable, so inline secrets are a credential leak. The
3409 /// field exists solely so the rejection is loud instead of a
3410 /// silent unknown-key skip. Use [`Self::api_key_env`] or
3411 /// [`Self::api_key_file`].
3412 pub api_key: Option<String>,
3413
3414 /// #1598 — name of the process env var holding the embedding API
3415 /// key. Mutually exclusive with [`Self::api_key_file`].
3416 pub api_key_env: Option<String>,
3417
3418 /// #1598 — path of a file holding the embedding API key (mode
3419 /// 0400 enforced, mirroring `[llm].api_key_file`). Mutually
3420 /// exclusive with [`Self::api_key_env`].
3421 pub api_key_file: Option<String>,
3422
3423 /// #1598 — explicit vector-dim override for embedding models not
3424 /// in [`KNOWN_EMBEDDING_DIMS`]. Takes precedence over the table
3425 /// lookup; non-positive values are ignored.
3426 pub dim: Option<u32>,
3427
3428 /// Backfill batch size. Bounded `1..=10000`; out-of-range values
3429 /// fall back to the compiled default (100) with a WARN. Env
3430 /// override: `AI_MEMORY_EMBED_BACKFILL_BATCH` (#38).
3431 pub backfill_batch: Option<u32>,
3432}
3433
3434/// v0.7.x (#1146) — `[reranker]` sectioned cross-encoder
3435/// configuration.
3436///
3437/// Wire format:
3438/// ```toml
3439/// [reranker]
3440/// enabled = true
3441/// model = "ms-marco-MiniLM-L-6-v2" # v0.7.0 has one variant;
3442/// # field reserved for future
3443/// # bake-offs.
3444/// ```
3445///
3446/// Folds the legacy `cross_encoder = bool` top-level flag. Migration
3447/// (via `ai-memory config migrate`) writes the explicit `enabled` +
3448/// `model` fold; the legacy field continues to be honored at parse
3449/// time until v0.8.0.
3450#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3451pub struct RerankerSection {
3452 /// Whether the cross-encoder rerank stage runs in the recall
3453 /// pipeline. Folded from `cross_encoder: Option<bool>` at the
3454 /// resolver layer.
3455 pub enabled: Option<bool>,
3456
3457 /// Cross-encoder model identifier. Defaults to
3458 /// `ms-marco-MiniLM-L-6-v2` when unset. Field reserved for future
3459 /// model bake-offs (e.g., `bge-reranker-v2-m3`,
3460 /// `mxbai-rerank-large-v2`).
3461 pub model: Option<String>,
3462
3463 /// #1604 — tokenized length cap for rerank inputs (the batched
3464 /// cross-encoder forward). Defaults to
3465 /// `crate::reranker::RERANK_MAX_SEQ_DEFAULT` when unset; values
3466 /// that are zero or above the model ceiling
3467 /// (`crate::reranker::CROSS_ENCODER_MAX_SEQ`) fall through.
3468 /// Overridable via `AI_MEMORY_RERANK_MAX_SEQ`.
3469 pub max_seq_tokens: Option<usize>,
3470
3471 /// #1691/n14 — recall-reranker score floor: drops low-confidence
3472 /// rerank candidates below a threshold so noise-band paraphrase
3473 /// matches do not surface. Value grammar (case-insensitive):
3474 /// `off` (default) | `absolute:<f>` (drop below an absolute blended
3475 /// score) | `relative:<f>` (drop below `top_score * f`). Parsed via
3476 /// [`crate::reranker::RerankerScoreFloor::parse`] and fed to
3477 /// [`crate::reranker::BatchedReranker::with_score_floor`] at every
3478 /// reranker build site. Overridable via `AI_MEMORY_RERANK_SCORE_FLOOR`.
3479 pub score_floor: Option<String>,
3480}
3481
3482/// #1671/n15 (v0.7.1) — `[curator]` sectioned per-namespace curator
3483/// configuration.
3484///
3485/// Wire format:
3486/// ```toml
3487/// # Per-namespace reflection-pass gate (#1671). `curator --reflect
3488/// # --all-namespaces` reflects ONLY namespaces listed here with
3489/// # `enabled = true`; a single `--namespace <ns>` invocation bypasses
3490/// # the gate (operator asked explicitly).
3491/// [curator.reflection_namespaces."team/eng"]
3492/// enabled = true
3493/// max_depth = 5
3494///
3495/// # Per-namespace confidence-decay half-life override, in days (n15).
3496/// # Absent → the compiled DEFAULT_HALF_LIFE_DAYS (30). Only consulted
3497/// # when the decay feature is enabled (AI_MEMORY_CONFIDENCE_DECAY=1).
3498/// [curator.confidence_decay_half_life_days]
3499/// "team/eng" = 14.0
3500/// ```
3501#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3502pub struct CuratorSection {
3503 /// #1671 — per-namespace reflection-pass overrides keyed by
3504 /// namespace. `curator --reflect --all-namespaces` participates ONLY
3505 /// for namespaces present here with `enabled = true`; absent /
3506 /// disabled namespaces are skipped (the conservative default that
3507 /// kept `--all-namespaces` a safe no-op before this wiring). Reuses
3508 /// the curator's own
3509 /// [`crate::curator::reflection_pass::ReflectionPassConfig`].
3510 #[serde(default)]
3511 pub reflection_namespaces: Option<
3512 std::collections::HashMap<String, crate::curator::reflection_pass::ReflectionPassConfig>,
3513 >,
3514
3515 /// n15 — per-namespace confidence-decay half-life override (days),
3516 /// keyed by namespace. Absent / non-finite / non-positive → the
3517 /// compiled [`crate::confidence::DEFAULT_HALF_LIFE_DAYS`]. Only
3518 /// consulted when confidence decay is enabled
3519 /// (`AI_MEMORY_CONFIDENCE_DECAY=1`).
3520 #[serde(default)]
3521 pub confidence_decay_half_life_days: Option<std::collections::HashMap<String, f64>>,
3522}
3523
3524/// v0.7.x (#1146) — `[storage]` sectioned storage configuration.
3525///
3526/// Wire format:
3527/// ```toml
3528/// [storage]
3529/// default_namespace = "alphaone"
3530/// archive_on_gc = true
3531/// archive_max_days = 90
3532/// max_memory_mb = 4096
3533/// ```
3534///
3535/// Carries the previously-flat top-level fields `default_namespace`,
3536/// `archive_on_gc`, `archive_max_days`, `max_memory_mb`. The `db`
3537/// path stays top-level per the #1146 I4 carve-out (path expansion
3538/// semantics pinned by #507).
3539#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3540pub struct StorageSection {
3541 /// Default namespace for new memories when the caller's request
3542 /// omits one. Folded from the previously-flat top-level
3543 /// `default_namespace` field.
3544 pub default_namespace: Option<String>,
3545
3546 /// Whether to archive memories before GC deletion. Folded from
3547 /// `archive_on_gc`. Default `true`.
3548 pub archive_on_gc: Option<bool>,
3549
3550 /// Archive retention ceiling in days. `None` (default) disables
3551 /// the automatic purge. Folded from `archive_max_days`.
3552 pub archive_max_days: Option<i64>,
3553
3554 /// Memory budget in MB for the auto tier selector. Folded from
3555 /// `max_memory_mb`.
3556 pub max_memory_mb: Option<usize>,
3557
3558 /// #1579 B7 — sqlite `PRAGMA mmap_size` in bytes. `0` disables
3559 /// memory-mapped I/O (stock SQLite semantics); negative values are
3560 /// treated as unset and fall through the ladder. Env override:
3561 /// `AI_MEMORY_DB_MMAP_SIZE` (see [`ENV_DB_MMAP_SIZE`]). Compiled
3562 /// default: 256 MiB
3563 /// ([`crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES`]) — the only
3564 /// across-the-board winner of the P1 perf-audit PRAGMA A/B
3565 /// (15-30% on large-corpus reads).
3566 pub db_mmap_size_bytes: Option<i64>,
3567}
3568
3569/// v0.7.x — `[limits]` sectioned operator-tunable capacity limits.
3570///
3571/// Wire format:
3572/// ```toml
3573/// [limits]
3574/// max_memories_per_day = 10000000 # per-(agent, namespace) daily write quota
3575/// max_storage_bytes = 1073741824 # per-(agent, namespace) lifetime byte cap
3576/// max_links_per_day = 5000 # per-(agent, namespace) daily link quota
3577/// max_page_size = 1000 # list/bulk request page-size ceiling
3578/// ```
3579///
3580/// Every field is optional; an omitted (or non-positive) value falls
3581/// through to the compiled default (`crate::quotas::DEFAULT_MAX_*` for
3582/// the three quota knobs, [`crate::handlers::MAX_BULK_SIZE`] for the
3583/// page-size cap). Resolved via [`AppConfig::resolve_limits`], which
3584/// also honours the `AI_MEMORY_MAX_*` env overrides at higher
3585/// precedence than the section.
3586///
3587/// **Operator guidance for `max_page_size`.** This bounds the number of
3588/// rows materialised into a single HTTP list response AND the number of
3589/// items accepted in a single bulk / federation-sync request. It is a
3590/// per-request in-memory bound, NOT a rate limit: a single request that
3591/// asks for (or carries) millions of rows allocates them all at once.
3592/// Raise it for bulk verification of a known-small corpus; for
3593/// genuinely large datasets paginate with `?offset=` / `?since=` rather
3594/// than removing the bound.
3595#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3596pub struct LimitsSection {
3597 /// Per-(agent, namespace) daily memory-write ceiling stamped at
3598 /// quota-row auto-insert. Folds nothing legacy; new at v0.7.x.
3599 pub max_memories_per_day: Option<i64>,
3600
3601 /// Per-(agent, namespace) lifetime storage cap in bytes.
3602 pub max_storage_bytes: Option<i64>,
3603
3604 /// Per-(agent, namespace) daily link-creation ceiling.
3605 pub max_links_per_day: Option<i64>,
3606
3607 /// Maximum items returned in a single list response / accepted in a
3608 /// single bulk or federation-sync request.
3609 pub max_page_size: Option<usize>,
3610}
3611
3612// ---------------------------------------------------------------------------
3613// Resolved-config shapes (#1146)
3614//
3615// Every surface that needs LLM / embedder / reranker / storage config
3616// consumes one of the `Resolved*` shapes below. The resolver methods on
3617// `AppConfig` (`resolve_llm` / `resolve_embeddings` / `resolve_reranker`
3618// / `resolve_storage`) produce them by applying the uniform precedence
3619// ladder:
3620//
3621// CLI flag > AI_MEMORY_* env var > config.toml section
3622// > legacy flat fields (with deprecation WARN once)
3623// > compiled default (CompiledDefault arm, WARN once)
3624//
3625// Resolvers are PURE (no network I/O). File reads for `api_key_file`
3626// happen at resolve time and surface errors via the `KeySource::Error`
3627// variant rather than panicking, so the daemon can boot and report the
3628// problem via the doctor reachability probe rather than failing at
3629// load time.
3630// ---------------------------------------------------------------------------
3631
3632/// Provenance tag for a resolved `Resolved*` field's value, surfaced by
3633/// the boot banner and `ai-memory doctor` so operators can see WHICH
3634/// source won the precedence ladder.
3635#[derive(Debug, Clone, PartialEq, Eq)]
3636pub enum ConfigSource {
3637 /// CLI flag (highest precedence).
3638 Cli,
3639 /// `AI_MEMORY_*` process environment variable.
3640 Env,
3641 /// `[llm]` / `[embeddings]` / `[reranker]` / `[storage]` section
3642 /// in `~/.config/ai-memory/config.toml`.
3643 Config,
3644 /// Legacy flat field in `~/.config/ai-memory/config.toml` (e.g.
3645 /// `llm_model = "gemma4:e4b"`). Triggers a one-shot deprecation
3646 /// WARN on `Config::load`.
3647 Legacy,
3648 /// Compiled-in default (no operator configuration). Triggers a
3649 /// one-shot WARN at resolve time so silent misconfigurations are
3650 /// loud.
3651 CompiledDefault,
3652}
3653
3654impl ConfigSource {
3655 #[must_use]
3656 pub fn as_str(&self) -> &'static str {
3657 match self {
3658 Self::Cli => "cli",
3659 Self::Env => "env",
3660 Self::Config => "config",
3661 Self::Legacy => "legacy",
3662 Self::CompiledDefault => "compiled-default",
3663 }
3664 }
3665}
3666
3667/// Provenance tag for a resolved API-key value.
3668#[derive(Debug, Clone, PartialEq, Eq)]
3669pub enum KeySource {
3670 /// `AI_MEMORY_LLM_API_KEY` process env var (highest precedence).
3671 ProcessEnv,
3672 /// Per-vendor process env-var fallback (`XAI_API_KEY`,
3673 /// `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.). The string field
3674 /// carries the name of the var that won (for observability).
3675 AliasFallback(String),
3676 /// `[llm].api_key_env` config-pointed env var. The string field
3677 /// carries the resolved env-var name.
3678 ConfigEnvVar(String),
3679 /// `[llm].api_key_file` config-pointed file path. The string field
3680 /// carries the resolved (tilde-expanded) path.
3681 ConfigFile(String),
3682 /// No API key resolved. Correct for `backend = "ollama"`
3683 /// (no auth); a misconfiguration for OpenAI-compatible vendors.
3684 None,
3685 /// Error reading the resolved key source. The string carries the
3686 /// human-readable error for the doctor probe to surface.
3687 Error(String),
3688}
3689
3690impl KeySource {
3691 #[must_use]
3692 pub fn as_str(&self) -> &'static str {
3693 match self {
3694 Self::ProcessEnv => "process-env",
3695 Self::AliasFallback(_) => "alias-fallback",
3696 Self::ConfigEnvVar(_) => "config-env-var",
3697 Self::ConfigFile(_) => "config-file",
3698 Self::None => "none",
3699 Self::Error(_) => "error",
3700 }
3701 }
3702
3703 /// True when the key was resolved from any source.
3704 #[must_use]
3705 pub fn is_present(&self) -> bool {
3706 !matches!(self, Self::None | Self::Error(_))
3707 }
3708}
3709
3710/// Canonical resolved-LLM configuration. Produced by
3711/// [`AppConfig::resolve_llm`]. Every LLM-init surface (MCP stdio,
3712/// HTTP daemon, `ai-memory atomise`, `ai-memory curator`,
3713/// embed-client fallback, boot banner) consumes this struct rather
3714/// than reading raw config / env / tier presets.
3715///
3716/// **Secret handling.** The `api_key` field is private; access via
3717/// `api_key()`. The `Debug` impl redacts the value (`<redacted>`).
3718#[derive(Clone, PartialEq, Eq)]
3719pub struct ResolvedLlm {
3720 /// Backend alias / wire-shape selector (e.g. `"ollama"`, `"xai"`,
3721 /// `"openai-compatible"`).
3722 pub backend: String,
3723 /// Model identifier passed verbatim to the chat endpoint.
3724 pub model: String,
3725 /// Base URL of the chat endpoint (vendor-default or operator
3726 /// override).
3727 pub base_url: String,
3728 /// Resolved API key. `None` for `backend = "ollama"` and for
3729 /// misconfigured backends; `Some` otherwise. Private — access via
3730 /// [`Self::api_key`] to keep accidental `{:?}` prints from
3731 /// leaking the value.
3732 api_key: Option<String>,
3733 /// Provenance of the resolved API key for boot-banner /
3734 /// doctor-probe display.
3735 pub api_key_source: KeySource,
3736 /// Provenance of the resolved configuration (CLI / env / config /
3737 /// legacy / compiled-default).
3738 pub source: ConfigSource,
3739}
3740
3741impl ResolvedLlm {
3742 /// Access the resolved API key. Use this only when constructing
3743 /// the LLM client; do NOT log or `{:?}` the result.
3744 #[must_use]
3745 pub fn api_key(&self) -> Option<&str> {
3746 self.api_key.as_deref()
3747 }
3748
3749 /// True when the resolved backend uses the Ollama-native wire
3750 /// shape (`/api/chat`, `/api/embed`, no auth). False for any
3751 /// OpenAI-compatible vendor.
3752 ///
3753 /// Compares `self.backend` against the canonical
3754 /// [`crate::llm::BACKEND_OLLAMA`] selector (#1174 PR4 substrate
3755 /// cleanup) so the literal lives in `llm.rs` alongside the rest
3756 /// of the vendor-alias tables instead of being re-named at each
3757 /// substrate site.
3758 #[must_use]
3759 pub fn is_ollama_native(&self) -> bool {
3760 self.backend == crate::llm::BACKEND_OLLAMA
3761 }
3762
3763 /// Display string for the boot banner: `<backend>:<model>`.
3764 #[must_use]
3765 pub fn display_label(&self) -> String {
3766 format!("{}:{}", self.backend, self.model)
3767 }
3768}
3769
3770impl std::fmt::Debug for ResolvedLlm {
3771 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3772 f.debug_struct("ResolvedLlm")
3773 .field("backend", &self.backend)
3774 .field("model", &self.model)
3775 .field("base_url", &self.base_url)
3776 .field(
3777 "api_key",
3778 &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3779 )
3780 .field("api_key_source", &self.api_key_source)
3781 .field("source", &self.source)
3782 .finish()
3783 }
3784}
3785
3786/// Canonical resolved-embedder configuration. Produced by
3787/// [`AppConfig::resolve_embeddings`].
3788///
3789/// **Secret handling (#1598).** The `api_key` field is private;
3790/// access via [`Self::api_key`]. The manual `Debug` impl redacts the
3791/// value (`<redacted>`), mirroring [`ResolvedLlm`].
3792#[derive(Clone, PartialEq, Eq)]
3793pub struct ResolvedEmbeddings {
3794 /// Embedding backend selector. `"ollama"` (local `/api/embed`
3795 /// wire shape) or, since #1598, any #1067 OpenAI-compatible alias
3796 /// / the generic `openai-compatible` escape hatch. Classify via
3797 /// [`is_api_embed_backend`].
3798 pub backend: String,
3799 /// Embedding endpoint base URL. The `[embeddings].base_url` /
3800 /// `[embeddings].url` synonym merge happens in the resolver
3801 /// (`base_url` wins); the field keeps the historical `url` name
3802 /// to limit call-site churn (#1598).
3803 pub url: String,
3804 /// Embedding model identifier (canonicalised — legacy aliases
3805 /// `nomic_embed_v15` / `mini_lm_l6_v2` are mapped to the
3806 /// `EmbeddingModel` enum's canonical HF id at resolve time).
3807 pub model: String,
3808 /// Backfill batch size. Bounded `1..=10000`; out-of-range values
3809 /// fall back to 100 with a WARN.
3810 pub backfill_batch: u32,
3811 /// v0.7.x (issue #1169) — vector dim of the resolved model, when
3812 /// known. #1598: the explicit `[embeddings].dim` override wins
3813 /// over the [`canonical_embedding_dim`] table lookup. `None` when
3814 /// the operator chose a model id that isn't in the table and set
3815 /// no override — in that case [`build_capability_models`] falls
3816 /// back to the tier preset's dim (preserving pre-#1169 behaviour
3817 /// for unrecognised ids and avoiding the silent-wrong-dim trap
3818 /// for the recognised ones).
3819 pub embedding_dim: Option<u32>,
3820 /// #1598 (fleet follow-up) — the EXPLICIT `[embeddings].dim`
3821 /// override only (never table-derived). For OpenAI-compatible
3822 /// backends this is also sent as the wire `dimensions` request
3823 /// param, so Matryoshka-capable API models (gemini-embedding-2,
3824 /// text-embedding-3-*) return truncated vectors at the operator's
3825 /// declared dim — the mechanism that keeps pgvector `vector(768)`
3826 /// fleet schemas + ANN indexes (≤2000-dim limit) usable with
3827 /// high-dim API models. `None` = model-native dim.
3828 pub requested_dim: Option<u32>,
3829 /// #1598 — resolved embedding API key. `None` for
3830 /// `backend = "ollama"` (no auth) and for keyless self-hosted
3831 /// OpenAI-compatible endpoints. Private — access via
3832 /// [`Self::api_key`].
3833 api_key: Option<String>,
3834 /// #1598 — provenance of the resolved API key for boot-banner /
3835 /// doctor-probe display.
3836 pub key_source: KeySource,
3837 /// Provenance of the resolved configuration.
3838 pub source: ConfigSource,
3839}
3840
3841impl ResolvedEmbeddings {
3842 /// Access the resolved embedding API key. Use this only when
3843 /// constructing the embed client; do NOT log or `{:?}` the result.
3844 #[must_use]
3845 pub fn api_key(&self) -> Option<&str> {
3846 self.api_key.as_deref()
3847 }
3848
3849 /// #1598 — construct from explicit parts. Prefer
3850 /// [`AppConfig::resolve_embeddings`]; this exists for tests and
3851 /// sibling surfaces (e.g. the reembed CLI) that synthesise a
3852 /// resolved view without an `AppConfig`.
3853 #[must_use]
3854 pub fn from_parts(
3855 backend: String,
3856 url: String,
3857 model: String,
3858 embedding_dim: Option<u32>,
3859 api_key: Option<String>,
3860 ) -> Self {
3861 Self {
3862 backend,
3863 url,
3864 model,
3865 backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
3866 embedding_dim,
3867 requested_dim: None,
3868 api_key,
3869 key_source: KeySource::None,
3870 source: ConfigSource::CompiledDefault,
3871 }
3872 }
3873
3874 /// #1598 (fleet follow-up) — builder for the explicit requested
3875 /// output dimensionality (see [`Self::requested_dim`]).
3876 #[must_use]
3877 pub fn with_requested_dim(mut self, dim: Option<u32>) -> Self {
3878 self.requested_dim = dim;
3879 self
3880 }
3881}
3882
3883impl std::fmt::Debug for ResolvedEmbeddings {
3884 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3885 f.debug_struct("ResolvedEmbeddings")
3886 .field("backend", &self.backend)
3887 .field("url", &self.url)
3888 .field("model", &self.model)
3889 .field("backfill_batch", &self.backfill_batch)
3890 .field(
3891 crate::models::field_names::EMBEDDING_DIM,
3892 &self.embedding_dim,
3893 )
3894 .field("requested_dim", &self.requested_dim)
3895 .field(
3896 "api_key",
3897 &self.api_key.as_ref().map(|_| crate::REDACTED_PLACEHOLDER),
3898 )
3899 .field("key_source", &self.key_source)
3900 .field("source", &self.source)
3901 .finish()
3902 }
3903}
3904
3905/// Canonical resolved-reranker configuration. Produced by
3906/// [`AppConfig::resolve_reranker`].
3907#[derive(Debug, Clone, PartialEq, Eq)]
3908pub struct ResolvedReranker {
3909 /// Whether the cross-encoder rerank stage runs.
3910 pub enabled: bool,
3911 /// Cross-encoder model identifier.
3912 pub model: String,
3913 /// #1604 — tokenized length cap for rerank inputs, resolved via
3914 /// `AI_MEMORY_RERANK_MAX_SEQ` env > `[reranker].max_seq_tokens` >
3915 /// `crate::reranker::RERANK_MAX_SEQ_DEFAULT`. Seeded into
3916 /// `crate::reranker::set_rerank_max_seq` at boot.
3917 pub max_seq_tokens: usize,
3918 /// Provenance of the resolved configuration.
3919 pub source: ConfigSource,
3920}
3921
3922/// Canonical resolved-storage configuration. Produced by
3923/// [`AppConfig::resolve_storage`].
3924#[derive(Debug, Clone, PartialEq, Eq)]
3925pub struct ResolvedStorage {
3926 /// Default namespace for new memories when the caller omits one.
3927 pub default_namespace: String,
3928 /// Whether to archive memories before GC deletion.
3929 pub archive_on_gc: bool,
3930 /// Archive retention ceiling in days (`None` = disabled).
3931 pub archive_max_days: Option<i64>,
3932 /// Memory budget in MB for the auto tier selector.
3933 pub max_memory_mb: Option<usize>,
3934 /// #1579 B7 — resolved sqlite `PRAGMA mmap_size` in bytes
3935 /// (`AI_MEMORY_DB_MMAP_SIZE` env > `[storage].db_mmap_size_bytes`
3936 /// > compiled 256 MiB default). `0` disables memory-mapped I/O.
3937 /// Seeded into `crate::storage::set_db_mmap_size` at boot.
3938 pub db_mmap_size_bytes: i64,
3939 /// #1590 — per-field provenance of `default_namespace`:
3940 /// [`ConfigSource::Config`] when `[storage].default_namespace` is
3941 /// explicitly set, [`ConfigSource::Legacy`] when only the
3942 /// deprecated flat `default_namespace` field is set, else
3943 /// [`ConfigSource::CompiledDefault`]. The section-level `source`
3944 /// tag below cannot express this — it reports `Config` whenever a
3945 /// `[storage]` section EXISTS even if `default_namespace` itself
3946 /// was never configured, and the write-path defaulting must only
3947 /// be overridden by an explicit operator choice (unconfigured
3948 /// deployments keep the historical per-surface ladders).
3949 pub default_namespace_source: ConfigSource,
3950 /// Provenance of the resolved configuration.
3951 pub source: ConfigSource,
3952}
3953
3954impl ResolvedStorage {
3955 /// #1590 — the operator-EXPLICITLY-configured default namespace,
3956 /// or `None` when `default_namespace` merely bottomed out at the
3957 /// compiled `"global"` default. Write-path consumers (MCP
3958 /// `memory_store`, HTTP `POST /api/v1/memories`, the CLI
3959 /// namespace ladder) only override their historical defaults when
3960 /// this returns `Some`.
3961 #[must_use]
3962 pub fn explicit_default_namespace(&self) -> Option<&str> {
3963 if self.default_namespace_source == ConfigSource::CompiledDefault {
3964 None
3965 } else {
3966 Some(self.default_namespace.as_str())
3967 }
3968 }
3969}
3970
3971// ---------------------------------------------------------------------------
3972// #1590 — process-wide operator-configured default namespace
3973// ---------------------------------------------------------------------------
3974
3975/// #1590 — process-wide operator-configured default namespace, seeded
3976/// once at boot by `crate::daemon_runtime::run` from
3977/// [`ResolvedStorage::explicit_default_namespace`]. `None` (the
3978/// unseeded / unconfigured state) preserves every surface's historical
3979/// default: MCP + HTTP store fall back to [`crate::DEFAULT_NAMESPACE`]
3980/// and the CLI falls back to its git-remote → cwd-basename → global
3981/// inference ladder. Mirrors the `crate::quotas::QuotaDefaults` /
3982/// `crate::storage::set_db_mmap_size` boot-seeding pattern for knobs
3983/// consumed where no `AppConfig` is in scope (serde default fns, MCP
3984/// param parsing, CLI helpers).
3985static CONFIGURED_DEFAULT_NAMESPACE: std::sync::RwLock<Option<String>> =
3986 std::sync::RwLock::new(None);
3987
3988/// #1590 — seed (or clear) the process-wide operator-configured
3989/// default namespace. Called once at boot; pass `None` for
3990/// deployments without an explicit `[storage].default_namespace`.
3991pub fn set_configured_default_namespace(namespace: Option<String>) {
3992 let mut slot = CONFIGURED_DEFAULT_NAMESPACE
3993 .write()
3994 .unwrap_or_else(std::sync::PoisonError::into_inner);
3995 *slot = namespace.filter(|s| !s.trim().is_empty());
3996}
3997
3998/// #1590 — the operator-configured default namespace, or `None` when
3999/// the operator never explicitly configured one (callers then apply
4000/// their historical per-surface default).
4001#[must_use]
4002pub fn configured_default_namespace() -> Option<String> {
4003 CONFIGURED_DEFAULT_NAMESPACE
4004 .read()
4005 .unwrap_or_else(std::sync::PoisonError::into_inner)
4006 .clone()
4007}
4008
4009/// Test-only gate serialising mutations of the process-wide
4010/// [`CONFIGURED_DEFAULT_NAMESPACE`] slot (same pattern as
4011/// [`lock_permissions_mode_for_test`]). Every test that seeds the slot
4012/// — or asserts the unseeded default — takes this guard first so
4013/// parallel tests cannot observe each other's transient state.
4014pub fn lock_configured_default_namespace_for_test() -> std::sync::MutexGuard<'static, ()> {
4015 static GATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4016 GATE_LOCK
4017 .lock()
4018 .unwrap_or_else(std::sync::PoisonError::into_inner)
4019}
4020
4021/// Canonical resolved operator-tunable capacity limits. Produced by
4022/// [`AppConfig::resolve_limits`]. Consumed at daemon boot to install the
4023/// quota-row auto-insert defaults (`crate::quotas::set_quota_defaults`)
4024/// and the HTTP list/bulk page-size cap (`AppState::max_page_size`).
4025#[derive(Debug, Clone, PartialEq, Eq)]
4026pub struct ResolvedLimits {
4027 /// Per-(agent, namespace) daily memory-write ceiling.
4028 pub max_memories_per_day: i64,
4029 /// Per-(agent, namespace) lifetime storage cap in bytes.
4030 pub max_storage_bytes: i64,
4031 /// Per-(agent, namespace) daily link-creation ceiling.
4032 pub max_links_per_day: i64,
4033 /// Maximum items per list response / bulk-or-sync request.
4034 pub max_page_size: usize,
4035 /// Provenance of the resolved configuration.
4036 pub source: ConfigSource,
4037}
4038
4039/// Env override for `[limits].max_memories_per_day`.
4040pub const ENV_MAX_MEMORIES_PER_DAY: &str = "AI_MEMORY_MAX_MEMORIES_PER_DAY";
4041/// Env override for `[limits].max_storage_bytes`.
4042pub const ENV_MAX_STORAGE_BYTES: &str = "AI_MEMORY_MAX_STORAGE_BYTES";
4043/// Env override for `[limits].max_links_per_day`.
4044pub const ENV_MAX_LINKS_PER_DAY: &str = "AI_MEMORY_MAX_LINKS_PER_DAY";
4045/// Env override for `[limits].max_page_size`.
4046pub const ENV_MAX_PAGE_SIZE: &str = "AI_MEMORY_MAX_PAGE_SIZE";
4047
4048/// #1579 B7 — env override for the sqlite `PRAGMA mmap_size`
4049/// (`[storage].db_mmap_size_bytes`), in whole bytes. `0` disables
4050/// memory-mapped I/O; negative / unparseable values fall through to
4051/// the `[storage]` section, then to the compiled 256 MiB default
4052/// (`crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES`).
4053pub const ENV_DB_MMAP_SIZE: &str = "AI_MEMORY_DB_MMAP_SIZE";
4054
4055/// #1604 — env override for the tokenized length of rerank inputs
4056/// (`[reranker].max_seq_tokens`), in tokens. Values that are zero,
4057/// unparseable, or above the model ceiling
4058/// (`crate::reranker::CROSS_ENCODER_MAX_SEQ`) fall through to the
4059/// `[reranker]` section, then to the compiled default
4060/// (`crate::reranker::RERANK_MAX_SEQ_DEFAULT`).
4061pub const ENV_RERANK_MAX_SEQ: &str = "AI_MEMORY_RERANK_MAX_SEQ";
4062
4063/// #1691/n14 — env override for the recall-reranker score floor.
4064/// Value grammar (case-insensitive): `off` | `absolute:<f>` |
4065/// `relative:<f>` (see [`crate::reranker::RerankerScoreFloor::parse`]).
4066/// Highest-precedence layer of the score-floor ladder
4067/// (env > `[reranker].score_floor` > compiled default
4068/// [`crate::reranker::RerankerScoreFloor::Off`]). Unparseable values
4069/// fall through to the next layer.
4070pub const ENV_RERANK_SCORE_FLOOR: &str = "AI_MEMORY_RERANK_SCORE_FLOOR";
4071
4072/// v0.7.0 (a) — env override for the postgres pool ceiling
4073/// (`postgres_pool_max_connections`). Byte-matches the name documented
4074/// in `docs/enterprise-deployment.md §5.6`.
4075pub const ENV_PG_POOL_MAX: &str = "AI_MEMORY_PG_POOL_MAX";
4076/// v0.7.0 (a) — env override for the postgres pool floor
4077/// (`postgres_pool_min_connections`). Byte-matches the name documented
4078/// in `docs/enterprise-deployment.md §5.6`.
4079pub const ENV_PG_POOL_MIN: &str = "AI_MEMORY_PG_POOL_MIN";
4080/// v0.7.0 (a) — env override for the pool acquire-timeout
4081/// (`postgres_acquire_timeout_secs`), in whole seconds.
4082pub const ENV_PG_ACQUIRE_TIMEOUT_SECS: &str = "AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS";
4083
4084/// #1067 — env carrying the LLM Bearer-auth secret; highest-precedence
4085/// layer of the `[llm]` API-key resolution ladder ([`KeySource`]).
4086pub const ENV_LLM_API_KEY: &str = "AI_MEMORY_LLM_API_KEY";
4087
4088/// #1598 — env override for the embedding backend selector
4089/// (`[embeddings].backend`). Same accepted values as the section
4090/// field: `ollama`, any #1067 alias, or `openai-compatible`.
4091pub const ENV_EMBED_BACKEND: &str = "AI_MEMORY_EMBED_BACKEND";
4092/// #1598 — env override for the embedding endpoint base URL
4093/// (`[embeddings].base_url` / `[embeddings].url`).
4094pub const ENV_EMBED_BASE_URL: &str = "AI_MEMORY_EMBED_BASE_URL";
4095/// #1598 — env override for the embedding model id
4096/// (`[embeddings].model`).
4097pub const ENV_EMBED_MODEL: &str = "AI_MEMORY_EMBED_MODEL";
4098/// #1598 — env carrying the embedding Bearer-auth secret;
4099/// highest-precedence layer of the `[embeddings]` API-key resolution
4100/// ladder (mirrors [`ENV_LLM_API_KEY`]).
4101pub const ENV_EMBED_API_KEY: &str = "AI_MEMORY_EMBED_API_KEY";
4102/// #38 — env override for the embedding backfill batch size
4103/// (`[embeddings].backfill_batch`). Hoisted from a raw literal in the
4104/// resolver per the no-hardcoded-literals discipline (#1598).
4105pub const ENV_EMBED_BACKFILL_BATCH: &str = "AI_MEMORY_EMBED_BACKFILL_BATCH";
4106
4107/// Compiled-default embedding model id (the v0.7.0 autonomous-tier
4108/// nomic default), shared by the resolver and its precedence tests.
4109pub(crate) const DEFAULT_EMBED_MODEL: &str = "nomic-embed-text-v1.5";
4110/// Compiled-default embedding backfill batch size.
4111pub(crate) const DEFAULT_EMBED_BACKFILL_BATCH: u32 = 100;
4112
4113/// v0.7.x (issue #1168) — bundle the three model-resolver outputs into
4114/// a single triple consumed by the capabilities surface. Lets callers
4115/// thread ONE struct through `handle_capabilities_with_conn` /
4116/// `handle_capabilities_with_conn_v3` / `build_capabilities_overlay`
4117/// instead of three independent borrows, and makes the contract loud:
4118/// `memory_capabilities.models.*` reflects the operator-resolved
4119/// configuration, NEVER the compiled tier preset.
4120///
4121/// **Production constructor:** [`AppConfig::resolve_models`].
4122/// **Test / back-compat constructor:** [`ResolvedModels::from_tier_preset`].
4123#[derive(Debug, Clone, PartialEq, Eq)]
4124pub struct ResolvedModels {
4125 /// Resolved LLM configuration (`AppConfig::resolve_llm`).
4126 pub llm: ResolvedLlm,
4127 /// Resolved embedder configuration (`AppConfig::resolve_embeddings`).
4128 pub embeddings: ResolvedEmbeddings,
4129 /// Resolved reranker configuration (`AppConfig::resolve_reranker`).
4130 pub reranker: ResolvedReranker,
4131}
4132
4133/// Compiled-default `ResolvedModels` triple. Equivalent to running
4134/// the resolvers against an [`AppConfig::default`] — Ollama backend,
4135/// no operator overrides, no API key, reranker disabled. Convenient
4136/// for test scaffolds that need a `ResolvedModels` value but don't
4137/// care about its contents.
4138impl Default for ResolvedModels {
4139 fn default() -> Self {
4140 Self {
4141 llm: ResolvedLlm {
4142 backend: "ollama".to_string(),
4143 model: String::new(),
4144 base_url: "http://localhost:11434".to_string(),
4145 api_key: None,
4146 api_key_source: KeySource::None,
4147 source: ConfigSource::CompiledDefault,
4148 },
4149 embeddings: ResolvedEmbeddings {
4150 backend: "ollama".to_string(),
4151 url: "http://localhost:11434".to_string(),
4152 model: String::new(),
4153 backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
4154 embedding_dim: None,
4155 requested_dim: None,
4156 api_key: None,
4157 key_source: KeySource::None,
4158 source: ConfigSource::CompiledDefault,
4159 },
4160 reranker: ResolvedReranker {
4161 enabled: false,
4162 model: "ms-marco-MiniLM-L-6-v2".to_string(),
4163 max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
4164 source: ConfigSource::CompiledDefault,
4165 },
4166 }
4167 }
4168}
4169
4170impl ResolvedModels {
4171 /// Back-compat constructor: synthesise a `ResolvedModels` triple
4172 /// from the compiled [`TierConfig`] preset alone.
4173 ///
4174 /// Yields the same [`CapabilityModels`] byte-for-byte that the
4175 /// pre-#1168 `TierConfig::capabilities()` produced, so legacy
4176 /// callers + tests that scaffold a `TierConfig` in isolation (no
4177 /// `AppConfig` available) continue to assert their original
4178 /// strings. The synthesised triple carries
4179 /// [`ConfigSource::CompiledDefault`] on every leaf so observers can
4180 /// distinguish a back-compat scaffold from an operator-resolved
4181 /// production triple.
4182 ///
4183 /// **Production paths** that have access to the operator
4184 /// [`AppConfig`] MUST use [`AppConfig::resolve_models`] instead.
4185 /// Using this helper in a production wrapper re-introduces the
4186 /// #1168 drift (the capabilities surface would report the tier
4187 /// preset instead of the operator-configured backend / model).
4188 #[must_use]
4189 pub fn from_tier_preset(tier: &TierConfig) -> Self {
4190 Self {
4191 llm: ResolvedLlm {
4192 backend: "ollama".to_string(),
4193 model: tier.llm_model.clone().unwrap_or_default(),
4194 base_url: "http://localhost:11434".to_string(),
4195 api_key: None,
4196 api_key_source: KeySource::None,
4197 source: ConfigSource::CompiledDefault,
4198 },
4199 embeddings: ResolvedEmbeddings {
4200 backend: "ollama".to_string(),
4201 url: "http://localhost:11434".to_string(),
4202 model: tier
4203 .embedding_model
4204 .map(|m| m.hf_model_id().to_string())
4205 .unwrap_or_default(),
4206 backfill_batch: DEFAULT_EMBED_BACKFILL_BATCH,
4207 // v0.7.x (#1169) — back-compat constructor: source the
4208 // dim from the tier-preset enum directly so the
4209 // ResolvedModels::from_tier_preset path matches the
4210 // pre-#1169 capabilities byte-shape (the test invariant
4211 // pinned by tests/issue_1168_*::from_tier_preset_*).
4212 embedding_dim: tier.embedding_model.map(|m| m.dim() as u32),
4213 requested_dim: None,
4214 api_key: None,
4215 key_source: KeySource::None,
4216 source: ConfigSource::CompiledDefault,
4217 },
4218 reranker: ResolvedReranker {
4219 enabled: tier.cross_encoder,
4220 // Back-compat: the pre-#1168 capabilities surface emitted
4221 // the full `cross-encoder/...` HF org-prefixed string when
4222 // the tier-preset enabled the cross-encoder. Preserve
4223 // that here so legacy assertions stay byte-equal.
4224 model: "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string(),
4225 max_seq_tokens: crate::reranker::RERANK_MAX_SEQ_DEFAULT,
4226 source: ConfigSource::CompiledDefault,
4227 },
4228 }
4229 }
4230}
4231
4232/// v0.7.0 (issue #518) — `[agents]` top-level block. Today only carries
4233/// the `defaults` sub-block (`[agents.defaults.recall_scope]`); future
4234/// agent-scoped knobs (per-agent quota overrides, per-agent autonomy
4235/// hook policy) can stack here without bloating the top-level
4236/// `AppConfig` surface.
4237///
4238/// Wire format:
4239/// ```toml
4240/// [agents.defaults.recall_scope]
4241/// namespaces = ["projects/atlas"]
4242/// since = "24h"
4243/// tier = "long"
4244/// limit = 50
4245/// ```
4246#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4247pub struct AgentsConfig {
4248 /// `[agents.defaults]` sub-block. `None` keeps recall semantics
4249 /// exactly as v0.6.x — every cross-session `memory_recall` requires
4250 /// explicit filters. `Some` enables `session_default=true` callers
4251 /// to splice these defaults into their request before storage
4252 /// dispatch.
4253 #[serde(default)]
4254 pub defaults: Option<AgentDefaults>,
4255}
4256
4257/// v0.7.0 (issue #518) — `[agents.defaults]` sub-block. Today exposes a
4258/// single field: `recall_scope`. Future expansion (per-call timeouts,
4259/// per-call tag filters, …) lives here.
4260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4261pub struct AgentDefaults {
4262 /// `[agents.defaults.recall_scope]` — default filter set spliced
4263 /// into recall calls that pass `session_default=true` and omit
4264 /// individual filter fields. See [`RecallScope`] for field
4265 /// semantics. `None` is equivalent to "no defaults configured".
4266 #[serde(default)]
4267 pub recall_scope: Option<RecallScope>,
4268}
4269
4270/// v0.7.0 (issue #518) — operator-configured recall defaults. Each
4271/// field is optional; when present and the inbound recall request
4272/// omits the corresponding axis AND passes `session_default=true`, the
4273/// handler splices in the configured value before dispatching to the
4274/// storage layer.
4275///
4276/// Resolution: **explicit request args > recall_scope defaults >
4277/// compiled defaults**. The splice never overrides an explicit filter
4278/// — operators can always narrow the result set further at call time.
4279///
4280/// Wire format:
4281/// ```toml
4282/// [agents.defaults.recall_scope]
4283/// namespaces = ["projects/atlas"] # default namespace filter
4284/// since = "24h" # duration → since = now() - 24h
4285/// tier = "long" # "short" / "mid" / "long"
4286/// limit = 50 # default cap
4287/// ```
4288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4289pub struct RecallScope {
4290 /// Default namespace filter applied when the request omits its
4291 /// own `namespace` field. The current recall handlers accept a
4292 /// single namespace per call; when multiple namespaces are
4293 /// configured we apply the first one. (The list form is future-
4294 /// compatible with a planned multi-namespace recall surface.)
4295 #[serde(default)]
4296 pub namespaces: Option<Vec<String>>,
4297 /// Default time-window applied when the request omits `since`.
4298 /// Expressed as a duration string: `"24h"`, `"7d"`, `"30m"`, … See
4299 /// [`parse_duration_string`] for the parser. The handler resolves
4300 /// it to `now() - duration` at request time and passes the
4301 /// resulting RFC3339 timestamp through the existing `since`
4302 /// filter — no new SQL path.
4303 #[serde(default)]
4304 pub since: Option<String>,
4305 /// Default tier filter applied when the request omits its own
4306 /// `tier`. Accepted values: `"short"` / `"mid"` / `"long"`. The
4307 /// sqlite recall handlers do not currently expose a tier
4308 /// parameter, so this knob is applied on the postgres SAL path
4309 /// (which carries a `Filter.tier`) and stored on the request
4310 /// envelope for forward-compatibility on sqlite (no observable
4311 /// behaviour change there).
4312 #[serde(default)]
4313 pub tier: Option<String>,
4314 /// Default recall limit applied when the request omits its own
4315 /// `limit`. The handler still clamps to the per-tool maximum
4316 /// (50) after applying this default, so an oversized value here
4317 /// degrades gracefully.
4318 #[serde(default)]
4319 pub limit: Option<u32>,
4320}
4321
4322/// v0.7.0 Cluster G (#767) — `[confidence]` config block. Carries the
4323/// retention window for `confidence_shadow_observations` consumed by
4324/// the periodic GC sweep wired into `daemon_runtime::spawn_gc_loop`.
4325///
4326/// Wire format:
4327/// ```toml
4328/// [confidence]
4329/// shadow_retention_days = 30
4330/// ```
4331///
4332/// `None` → the compiled default
4333/// ([`crate::confidence::shadow::DEFAULT_SHADOW_RETENTION_DAYS`] = 30)
4334/// applies. Set to `0` or a negative value to disable the sweep
4335/// (matches the audit-honest "do-nothing-on-zero" convention used by
4336/// `archive_max_days`).
4337#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
4338pub struct ConfidenceConfig {
4339 /// Retention window (in days) for shadow-mode observation rows.
4340 /// Rows whose `observed_at` is older than `now - N days` are
4341 /// deleted by the GC sweep. `None` → compiled default of 30 days.
4342 /// `Some(0)` or `Some(<0)` → sweep is a no-op (operator opt-out
4343 /// for compliance / forensic-retention scenarios).
4344 pub shadow_retention_days: Option<i64>,
4345}
4346
4347impl ConfidenceConfig {
4348 /// Effective retention window, honoring the compiled default when
4349 /// the config block is absent or `shadow_retention_days` is unset.
4350 #[must_use]
4351 pub fn effective_shadow_retention_days(&self) -> i64 {
4352 self.shadow_retention_days
4353 .unwrap_or(crate::confidence::shadow::DEFAULT_SHADOW_RETENTION_DAYS)
4354 }
4355}
4356
4357/// v0.7.0 H7 (round-2) — compiled default per-request HTTP timeout.
4358/// Applied when `AppConfig::request_timeout_secs` is `None`.
4359pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
4360
4361/// v0.7.0 H8 (round-2) — compiled default per-LLM-call timeout.
4362/// Applied when `AppConfig::llm_call_timeout_secs` is `None`.
4363pub const DEFAULT_LLM_CALL_TIMEOUT_SECS: u64 = 30;
4364
4365// ---------------------------------------------------------------------------
4366// Hooks / subscription HMAC (K7)
4367// ---------------------------------------------------------------------------
4368
4369/// `[hooks]` config block. v0.7.0 K7 — operator-facing knobs for the
4370/// outgoing-webhook surface.
4371///
4372/// Wire format:
4373/// ```toml
4374/// [hooks.subscription]
4375/// hmac_secret = "<plaintext-secret>"
4376/// ```
4377///
4378/// When `hmac_secret` is set, EVERY outbound webhook payload is signed
4379/// with `HMAC-SHA256(hmac_secret, "<timestamp>.<body>")` and the hex
4380/// digest is sent as the `X-AI-Memory-Signature: sha256=<hex>` header.
4381/// The override applies even to subscriptions that did not register a
4382/// per-subscription secret. When both are set, the per-subscription
4383/// secret wins (subscription-scoped trust beats server-scoped trust).
4384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4385pub struct HooksConfig {
4386 /// `[hooks.subscription]` sub-block. Optional — when omitted, no
4387 /// server-wide HMAC override applies.
4388 pub subscription: Option<HooksSubscriptionConfig>,
4389}
4390
4391/// `[hooks.subscription]` sub-block. K7 ships one knob today
4392/// (`hmac_secret`); future K-track work may add per-event opt-out
4393/// filters or alternate signing algorithms.
4394///
4395/// #1262 — `Debug` is implemented manually to redact `hmac_secret` so
4396/// accidental `{:?}` prints never leak the signing key. #1258 — the
4397/// manual `Drop` impl zeroizes the secret on scope exit.
4398#[derive(Clone, Default, Serialize, Deserialize)]
4399pub struct HooksSubscriptionConfig {
4400 /// Server-wide HMAC secret. Plaintext on disk — operators are
4401 /// expected to chmod 600 the config file (same posture as the
4402 /// existing `api_key` field).
4403 ///
4404 /// #1262 — `skip_serializing` blocks the secret from being echoed
4405 /// through any `serde_json::to_string(&HooksSubscriptionConfig)`
4406 /// path.
4407 #[serde(default, skip_serializing)]
4408 pub hmac_secret: Option<String>,
4409}
4410
4411impl std::fmt::Debug for HooksSubscriptionConfig {
4412 /// #1262 — redact `hmac_secret` to `<redacted>` when present.
4413 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4414 f.debug_struct("HooksSubscriptionConfig")
4415 .field(
4416 "hmac_secret",
4417 &self
4418 .hmac_secret
4419 .as_ref()
4420 .map(|_| crate::REDACTED_PLACEHOLDER),
4421 )
4422 .finish()
4423 }
4424}
4425
4426impl HooksSubscriptionConfig {
4427 /// #1258 — zeroize the `hmac_secret` buffer in place. Idempotent.
4428 /// The `Drop` impl below delegates here so the helper is the
4429 /// single source of truth for the zero-on-secret-loss contract.
4430 /// Tests probe the buffer via this entry point so they observe
4431 /// the post-zeroize state of a still-live allocation (probing
4432 /// after the owning value is dropped is UB — the allocator's
4433 /// free-list bookkeeping stamps the first 8-16 bytes of the
4434 /// just-freed slot and that's not a `zeroize` defect; see #1321).
4435 pub fn zeroize_secrets(&mut self) {
4436 if let Some(secret) = self.hmac_secret.as_mut() {
4437 use zeroize::Zeroize;
4438 secret.zeroize();
4439 }
4440 }
4441}
4442
4443impl Drop for HooksSubscriptionConfig {
4444 /// #1258 — zeroize `hmac_secret` on scope exit. Delegates to
4445 /// [`HooksSubscriptionConfig::zeroize_secrets`].
4446 fn drop(&mut self) {
4447 self.zeroize_secrets();
4448 }
4449}
4450
4451/// v0.7.0 H5 (round-2) — `[verify]` config block. Operator-facing
4452/// knobs for `POST /api/v1/links/verify`. Today exposes one knob:
4453/// `require_nonce` (default `false`).
4454///
4455/// Wire format:
4456/// ```toml
4457/// [verify]
4458/// require_nonce = true # strict mode — every verify request
4459/// # must carry verification_nonce
4460/// ```
4461///
4462/// When `require_nonce = false` (the default), the handler logs a
4463/// deprecation WARN when a request omits `verification_nonce` but
4464/// still allows it through. When `true`, missing nonces are rejected
4465/// with 409 Conflict and the operator's audit trail receives every
4466/// attempted reuse.
4467#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4468pub struct VerifyConfig {
4469 /// When `true`, `POST /api/v1/links/verify` requires every
4470 /// request body to include a `verification_nonce` field. Missing
4471 /// or empty nonces produce a 400 Bad Request. Already-seen
4472 /// `(link_id, signature, nonce)` tuples produce a 409 Conflict
4473 /// with `{"error":"verification replay detected"}`. Default `false`
4474 /// preserves the v0.6.x verify-anytime semantics; operators
4475 /// opting into the H5 replay-protection guarantee set this to
4476 /// `true` after their clients have been updated to emit nonces.
4477 #[serde(default)]
4478 pub require_nonce: bool,
4479}
4480
4481/// v0.7.0 H11 (#628 blocker) — `[subscriptions]` block. Operator
4482/// knobs for the outgoing-webhook surface that are NOT specific to
4483/// HMAC signing (which lives under `[hooks.subscription]`).
4484///
4485/// Wire format:
4486/// ```toml
4487/// [subscriptions]
4488/// allow_loopback_webhooks = true # default false; opt-in for testing
4489/// ```
4490///
4491/// When unset (or false), the SSRF guard rejects webhook URLs that
4492/// resolve to loopback addresses (`127.0.0.0/8`, `localhost`, `::1`).
4493/// Loopback hosts are reachable from the daemon process itself, so
4494/// permitting them by default exposes any locally-bound service
4495/// (database, internal admin sockets) to authenticated SSRF.
4496#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4497pub struct SubscriptionsConfig {
4498 /// Re-enable loopback webhook URLs. Default `false` (loopback
4499 /// rejected). Operators who need to point a webhook at a local
4500 /// listener (CI, dev) set this to `true` explicitly.
4501 #[serde(default)]
4502 pub allow_loopback_webhooks: bool,
4503}
4504
4505impl AppConfig {
4506 /// v0.7.0 K7 — resolved server-wide webhook HMAC secret. `None`
4507 /// means no server-wide override (per-subscription secrets still
4508 /// apply via the legacy code path).
4509 #[must_use]
4510 pub fn effective_hooks_hmac_secret(&self) -> Option<String> {
4511 self.hooks
4512 .as_ref()
4513 .and_then(|h| h.subscription.as_ref())
4514 .and_then(|s| s.hmac_secret.clone())
4515 }
4516
4517 /// v0.7.0 (issue #518) — resolved `[agents.defaults.recall_scope]`
4518 /// block. Returns `Some(&scope)` when configured, `None` otherwise.
4519 /// Consumed by the recall handlers (sqlite + postgres SAL branches,
4520 /// MCP `handle_recall`, CLI `cmd_recall`) to splice defaults into
4521 /// requests that pass `session_default=true` and omit one or more
4522 /// filter fields.
4523 #[must_use]
4524 pub fn effective_recall_scope(&self) -> Option<&RecallScope> {
4525 self.agents
4526 .as_ref()
4527 .and_then(|a| a.defaults.as_ref())
4528 .and_then(|d| d.recall_scope.as_ref())
4529 }
4530
4531 /// v0.7.0 H11 (#628 blocker) — resolved loopback-webhook opt-in
4532 /// flag. Defaults to `false` (loopback rejected — closes the
4533 /// authenticated SSRF gadget against local services). Operators
4534 /// who need loopback for testing set
4535 /// `[subscriptions] allow_loopback_webhooks = true`.
4536 ///
4537 /// Resolution order (mirrors `effective_permissions_mode`):
4538 /// 1. `AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS` env var (`1` / `true` —
4539 /// case-insensitive). Lets the integration suite — which
4540 /// sets `AI_MEMORY_NO_CONFIG=1` and therefore cannot use
4541 /// `[subscriptions]` from `config.toml` — bind wiremock at
4542 /// `127.0.0.1:0` and drive webhooks through it without
4543 /// touching the production default.
4544 /// 2. `[subscriptions].allow_loopback_webhooks` from `config.toml`.
4545 /// 3. Compiled default (`false` — loopback rejected).
4546 #[must_use]
4547 pub fn effective_allow_loopback_webhooks(&self) -> bool {
4548 if let Ok(raw) = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS") {
4549 match raw.to_ascii_lowercase().as_str() {
4550 "1" | "true" | "yes" | "on" => return true,
4551 "0" | "false" | "no" | "off" | "" => return false,
4552 other => {
4553 eprintln!(
4554 "ai-memory: AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS={other:?} is not a valid \
4555 boolean (expected 1/true/yes/on or 0/false/no/off); falling back to \
4556 config.toml"
4557 );
4558 }
4559 }
4560 }
4561 self.subscriptions
4562 .as_ref()
4563 .is_some_and(|s| s.allow_loopback_webhooks)
4564 }
4565}
4566
4567// ---------------------------------------------------------------------------
4568// Process-wide handle for the K7 server-wide HMAC override.
4569// Mirrors the `ACTIVE_PERMISSIONS_MODE` pattern: set once at boot,
4570// read by `subscriptions::dispatch_event_with_details` without an
4571// API churn through every callsite.
4572//
4573// v0.7.x (issue #1174 follow-up #1192) — storage moved to
4574// `RuntimeContext::hooks_hmac_secret` so the HTTP daemon, the MCP
4575// stdio binary, and the CLI all share one source of truth. The
4576// accessors below delegate to the process-wide singleton; the wire
4577// semantics + the K7 integration-test fixture (which flips the value
4578// mid-process) are byte-equivalent.
4579// ---------------------------------------------------------------------------
4580
4581/// v0.7.0 K7 — set the process-wide webhook HMAC override. Called from
4582/// `main`/daemon bootstrap with the value from
4583/// `[hooks.subscription] hmac_secret`. Last writer wins — this is
4584/// production-safe because boot only invokes it once; tests use the
4585/// same setter to flip mid-process.
4586///
4587/// v0.7.x (issue #1192) — delegates to
4588/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4589/// lives on `RuntimeContext::hooks_hmac_secret`.
4590pub fn set_active_hooks_hmac_secret(secret: Option<String>) {
4591 if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
4592 .hooks_hmac_secret
4593 .write()
4594 {
4595 *w = secret;
4596 }
4597}
4598
4599/// v0.7.0 K7 — read the process-wide webhook HMAC override. Returns
4600/// `None` when unset (the K6-and-earlier behaviour: only
4601/// per-subscription secrets sign outgoing payloads).
4602///
4603/// v0.7.x (issue #1192) — delegates to
4604/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4605/// lives on `RuntimeContext::hooks_hmac_secret`.
4606#[must_use]
4607pub fn active_hooks_hmac_secret() -> Option<String> {
4608 crate::runtime_context::RuntimeContext::global()
4609 .hooks_hmac_secret
4610 .read()
4611 .ok()
4612 .and_then(|g| g.clone())
4613}
4614
4615// ---------------------------------------------------------------------------
4616// I1 cap (#628 agent-3 follow-up) — process-wide transcript decompression cap
4617// ---------------------------------------------------------------------------
4618//
4619// `transcripts::fetch` consults this getter to decide the maximum
4620// number of bytes a single transcript may decompress to. Operators
4621// who legitimately store >16 MiB transcripts raise the cap explicitly
4622// via `[transcripts] max_decompressed_bytes = …`; default-on uses the
4623// compiled `MAX_DECOMPRESSED_BYTES` constant. The cap is per-call;
4624// concurrent fetches consume up to N × this value of transient memory.
4625//
4626// v0.7.x (issue #1174 follow-up #1192) — storage moved to
4627// `RuntimeContext::max_decompressed_bytes`. The accessors below
4628// delegate; the per-call cap semantics are byte-equivalent.
4629
4630/// Set the process-wide decompression cap. Boot reads
4631/// `[transcripts] max_decompressed_bytes` and calls this; tests flip
4632/// mid-process to exercise both branches.
4633///
4634/// v0.7.x (issue #1192) — delegates to
4635/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4636/// lives on `RuntimeContext::max_decompressed_bytes`.
4637pub fn set_active_max_decompressed_bytes(cap: Option<usize>) {
4638 if let Ok(mut w) = crate::runtime_context::RuntimeContext::global()
4639 .max_decompressed_bytes
4640 .write()
4641 {
4642 *w = cap;
4643 }
4644}
4645
4646/// Read the process-wide decompression cap, falling back to the
4647/// compiled default when unset.
4648///
4649/// v0.7.x (issue #1192) — delegates to
4650/// [`crate::runtime_context::RuntimeContext::global`]; the storage
4651/// lives on `RuntimeContext::max_decompressed_bytes`.
4652#[must_use]
4653pub fn active_max_decompressed_bytes() -> usize {
4654 crate::runtime_context::RuntimeContext::global()
4655 .max_decompressed_bytes
4656 .read()
4657 .ok()
4658 .and_then(|g| *g)
4659 .unwrap_or(crate::transcripts::MAX_DECOMPRESSED_BYTES)
4660}
4661
4662// ---------------------------------------------------------------------------
4663// H11 — process-wide handle for the loopback-webhook opt-in
4664// ---------------------------------------------------------------------------
4665//
4666// `validate_url` in `subscriptions.rs` consults this handle to decide
4667// whether to accept loopback webhook destinations. Default-OFF closes
4668// the SSRF gadget; the boot code in `main` / daemon reads
4669// `[subscriptions] allow_loopback_webhooks` and sets the flag here.
4670
4671// Default-OFF in production builds so the SSRF guard rejects loopback
4672// without explicit opt-in. Defaults to `true` under `cfg(test)` so
4673// the existing test surface (which binds wiremock to `127.0.0.1:0`
4674// and drives validate_url/validate_url_dns through real loopback
4675// URLs) passes without 16-test fan-out modifications. The H11
4676// default-OFF behaviour is independently asserted via the
4677// `validate_url_with` / `validate_url_dns_check_addrs` inner helpers
4678// in `subscriptions.rs`, so flipping the test-build default here
4679// does NOT relax the H11 ship-gate test coverage.
4680static ALLOW_LOOPBACK_WEBHOOKS: std::sync::atomic::AtomicBool =
4681 std::sync::atomic::AtomicBool::new(cfg!(test));
4682
4683/// v0.7.0 H11 — set the process-wide loopback-webhook opt-in. Called
4684/// from boot with the value of `[subscriptions] allow_loopback_webhooks`.
4685/// Defaults to `false` (loopback rejected).
4686pub fn set_allow_loopback_webhooks(allow: bool) {
4687 ALLOW_LOOPBACK_WEBHOOKS.store(allow, std::sync::atomic::Ordering::SeqCst);
4688}
4689
4690/// v0.7.0 H11 — read the process-wide loopback-webhook opt-in.
4691/// Returns `false` when unset (the safe default — loopback URLs are
4692/// rejected by the SSRF guard).
4693#[must_use]
4694pub fn allow_loopback_webhooks() -> bool {
4695 ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst)
4696}
4697
4698// ---------------------------------------------------------------------------
4699// Permissions / governance gate (K3)
4700// ---------------------------------------------------------------------------
4701
4702/// Enforcement posture consulted by [`crate::db::enforce_governance`].
4703///
4704/// v0.7.0 K3 — closes the v0.6.3.1 honest-Capabilities-v2 disclosure
4705/// that `permissions.mode = "advisory"` was advertised but the gate
4706/// itself returned `Deny` / `Pending` regardless. The gate now actually
4707/// honors this knob.
4708///
4709/// Wire format on `config.toml`:
4710///
4711/// ```toml
4712/// [permissions]
4713/// mode = "advisory" # or "enforce" / "off"
4714/// ```
4715#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4716#[serde(rename_all = "lowercase")]
4717pub enum PermissionsMode {
4718 /// Block on policy violation. `Deny`/`Pending` decisions returned
4719 /// to the caller as-is. The strict, audit-ready posture.
4720 Enforce,
4721 /// Log a warning and allow the action. Governance metadata is
4722 /// recorded but does not block writes. Default for v0.7.0 to
4723 /// preserve the v0.6.x posture for upgrading operators.
4724 Advisory,
4725 /// Skip the gate entirely. No policy resolution, no log, no
4726 /// `pending_actions` row. Useful for benchmarking and temporary
4727 /// freeze-thaw incident response.
4728 Off,
4729}
4730
4731impl Default for PermissionsMode {
4732 fn default() -> Self {
4733 Self::Advisory
4734 }
4735}
4736
4737impl PermissionsMode {
4738 /// Lowercase wire string for capabilities + doctor surfaces.
4739 #[must_use]
4740 pub fn as_str(self) -> &'static str {
4741 match self {
4742 Self::Enforce => "enforce",
4743 Self::Advisory => "advisory",
4744 Self::Off => "off",
4745 }
4746 }
4747}
4748
4749/// `[permissions]` block in `config.toml`. Carries the gate's
4750/// enforcement posture and (v0.7.0 K9) the declarative rule list
4751/// the unified [`crate::permissions::Permissions::evaluate`]
4752/// pipeline consults before mode + hook fall-through.
4753///
4754/// Wire format (rules — K9):
4755///
4756/// ```toml
4757/// [permissions]
4758/// mode = "enforce"
4759///
4760/// [[permissions.rules]]
4761/// namespace_pattern = "secrets/*"
4762/// op = "memory_store"
4763/// agent_pattern = "ai:*"
4764/// decision = "deny"
4765/// reason = "ai agents may not write to secrets"
4766/// ```
4767///
4768/// Rules are deny-first and longest-pattern-wins; see
4769/// [`crate::permissions`] module docs for the full combination
4770/// rule.
4771#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4772pub struct PermissionsConfig {
4773 /// Enforcement mode. `None` when the operator declared a
4774 /// `[permissions]` block but omitted `mode = ` — this is the
4775 /// "partial config" case that B4 (S5-M3) closes: such a block
4776 /// MUST NOT silently fall back to the serde-derived
4777 /// `PermissionsMode::default` (`advisory`), because the v0.7.0
4778 /// secure default is `enforce`. The
4779 /// [`AppConfig::effective_permissions_mode`] resolver maps
4780 /// `Some(cfg { mode: None })` to the secure default + a
4781 /// migration warning, so an operator who half-typed
4782 /// `[permissions]` and forgot the mode line still ships
4783 /// `enforce`, not the v0.6.x advisory posture.
4784 ///
4785 /// Serializes as omitted when `None` so a round-tripped config
4786 /// without an explicit `mode` keeps the partial-config shape
4787 /// for the next loader.
4788 #[serde(default, skip_serializing_if = "Option::is_none")]
4789 pub mode: Option<PermissionsMode>,
4790 /// v0.7.0 K9 — declarative permission rules. Each entry is a
4791 /// `(namespace_pattern, op, agent_pattern, decision)` tuple
4792 /// consulted by [`crate::permissions::Permissions::evaluate`]
4793 /// before the mode default falls through. Defaults to empty
4794 /// (no declarative rules — pre-K9 behaviour: mode + hooks +
4795 /// existing governance gate decide everything).
4796 #[serde(default)]
4797 pub rules: Vec<crate::permissions::PermissionRule>,
4798}
4799
4800// ---------------------------------------------------------------------------
4801// Process-wide permissions-mode handle (K3)
4802// ---------------------------------------------------------------------------
4803//
4804// The gate (`db::enforce_governance`) needs to consult the active mode
4805// at decision time but lives in the `db` module, which has no handle on
4806// `AppConfig`. We hold the active mode in a single `RwLock<Option<…>>`
4807// set by `main` (and the daemon runtime) so the gate can read the mode
4808// without an API churn through every callsite. When the lock is unset
4809// — the case for unit and integration tests that drive
4810// `db::enforce_governance` directly without booting the daemon — the
4811// gate defaults to [`PermissionsMode::Advisory`] (the v0.7.0 K3
4812// secure-but-non-blocking posture). Tests opt into `Enforce` via the
4813// `set_active_permissions_mode` setter or the
4814// `override_active_permissions_mode_for_test` alias.
4815//
4816// **#1174 pm-v3.1 PR7 (this commit)**: collapsed the previous
4817// dual-source-of-truth (a `OnceLock<PermissionsMode>` for production +
4818// an `AtomicU8` test-only override that secretly took precedence over
4819// it) into a single `RwLock<Option<PermissionsMode>>`. The previous
4820// `OnceLock` shape blocked legitimate runtime reload paths — a SIGHUP
4821// handler that wanted to re-resolve `[permissions].mode` from
4822// `config.toml` and call `set_active_permissions_mode` again would
4823// silently no-op, leaving the gate on the boot-time value while every
4824// other resolver caught the new value. The new shape supports
4825// last-writer-wins so a future SIGHUP / `ai-memory reload` surface
4826// can refresh the mode without restart. The test-override semantics
4827// are preserved: tests still hold the
4828// [`lock_permissions_mode_for_test`] guard around their mutations and
4829// the public setter / overrider signatures are unchanged.
4830
4831static ACTIVE_PERMISSIONS_MODE: std::sync::RwLock<Option<PermissionsMode>> =
4832 std::sync::RwLock::new(None);
4833
4834/// Set the process-wide active [`PermissionsMode`]. Called from `main`
4835/// (CLI) and the daemon bootstrap path with the value resolved from
4836/// `[permissions].mode` in `config.toml`. Last-writer-wins so a future
4837/// SIGHUP / `ai-memory reload` surface can refresh the mode without
4838/// restart (#1174 PR7); the previous `OnceLock` shape made repeat
4839/// callers silently no-op.
4840pub fn set_active_permissions_mode(mode: PermissionsMode) {
4841 if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
4842 *w = Some(mode);
4843 }
4844}
4845
4846/// The pre-initialization fallback mode for [`active_permissions_mode`].
4847///
4848/// Every production entry point (CLI, MCP, HTTP `serve`) resolves the
4849/// real mode via [`AppConfig::effective_permissions_mode`] — whose
4850/// v0.7.0 secure default is [`PermissionsMode::Enforce`] — and installs
4851/// it via [`set_active_permissions_mode`] during boot, BEFORE any write
4852/// can reach the governance gate. This constant is therefore only ever
4853/// observed when the gate is consulted before boot ran (a library
4854/// embedding that never called the setter, or a unit test that does not
4855/// opt into a specific mode). It is held at `Advisory` to preserve the
4856/// historical pre-init behaviour the test suite relies on; the
4857/// [`active_permissions_mode`] reader emits a one-shot WARN when it has
4858/// to fall back to this value so the uninitialized-gate condition is
4859/// observable rather than silent.
4860const UNINITIALIZED_PERMISSIONS_MODE_FALLBACK: PermissionsMode = PermissionsMode::Advisory;
4861
4862/// Read the process-wide active [`PermissionsMode`] installed at boot by
4863/// [`set_active_permissions_mode`] (sourced from
4864/// [`AppConfig::effective_permissions_mode`], whose v0.7.0 secure
4865/// default is [`PermissionsMode::Enforce`]).
4866///
4867/// When the slot is unset — i.e. boot has NOT run — this returns
4868/// [`UNINITIALIZED_PERMISSIONS_MODE_FALLBACK`] and emits a one-shot
4869/// operator-visible WARN, because consulting the governance gate before
4870/// the mode is installed is a defense-in-depth gap: the gate would run
4871/// against the pre-init fallback rather than the operator's resolved
4872/// mode. In production this path is unreachable (boot always installs
4873/// the mode first); the WARN exists to surface a regression if that
4874/// ordering ever breaks.
4875///
4876/// Test note: the K1 ship-gate matrix asserts `Pending`/`Deny`
4877/// outcomes from `db::enforce_governance` and therefore opts into
4878/// `Enforce` via [`set_active_permissions_mode`] at the start of each
4879/// scenario.
4880#[must_use]
4881pub fn active_permissions_mode() -> PermissionsMode {
4882 match ACTIVE_PERMISSIONS_MODE.read().ok().and_then(|g| *g) {
4883 Some(mode) => mode,
4884 None => {
4885 static UNINIT_GATE_WARN_ONCE: std::sync::Once = std::sync::Once::new();
4886 UNINIT_GATE_WARN_ONCE.call_once(|| {
4887 tracing::warn!(
4888 target: crate::governance::GOVERNANCE_TRACE_TARGET,
4889 fallback = UNINITIALIZED_PERMISSIONS_MODE_FALLBACK.as_str(),
4890 "permissions mode consulted before boot installed it; using the \
4891 pre-init fallback. Production entry points install the resolved \
4892 mode (secure default: enforce) during boot — if you see this in \
4893 a running daemon, the boot ordering regressed."
4894 );
4895 });
4896 UNINITIALIZED_PERMISSIONS_MODE_FALLBACK
4897 }
4898 }
4899}
4900
4901/// Test-only override of the active mode. Production code MUST use
4902/// [`set_active_permissions_mode`]; this helper exists so the K3 test
4903/// matrix can flip mode mid-test without spinning up a fresh process.
4904///
4905/// **#1174 PR7**: with the dual-source-of-truth collapse the override
4906/// is now a thin alias around [`set_active_permissions_mode`]. The
4907/// two functions are wire-equivalent at every callsite. The alias is
4908/// kept (rather than renaming all test callers in one pass) because
4909/// the `_for_test` suffix at every callsite documents the intent —
4910/// "this is a test poking the global gate" — better than an
4911/// unsuffixed setter would.
4912#[doc(hidden)]
4913pub fn override_active_permissions_mode_for_test(mode: PermissionsMode) {
4914 set_active_permissions_mode(mode);
4915}
4916
4917/// Test-only: clear any test-override so subsequent tests start from
4918/// the unset state (the [`PermissionsMode::Advisory`] default).
4919///
4920/// **#1174 PR7**: previously this cleared the `OVERRIDE_PERMISSIONS_MODE`
4921/// atomic without touching the production-side `OnceLock`, which let
4922/// a test that called the production setter once leak its value into
4923/// the next test. With the single-source-of-truth collapse, clearing
4924/// resets the lone slot — subsequent reads see `Advisory` until the
4925/// next setter call, which is the documented contract.
4926#[doc(hidden)]
4927pub fn clear_permissions_mode_override_for_test() {
4928 if let Ok(mut w) = ACTIVE_PERMISSIONS_MODE.write() {
4929 *w = None;
4930 }
4931}
4932
4933/// Test-only: acquire the global gate-mode serialization lock.
4934///
4935/// The active [`PermissionsMode`] lives in a process-wide atomic so
4936/// the gate at `db::enforce_governance` can read it without an API
4937/// churn through every callsite. Multiple lib tests flip the mode
4938/// (the K3 mode-matrix file, the CLI / HTTP gate scenarios, the
4939/// capabilities zero-state round-trip) and `cargo test --lib` runs
4940/// them in parallel by default. Each scenario MUST hold this guard
4941/// for its duration so two scenarios cannot race the atomic. The
4942/// returned guard poisons-OK so one panicking scenario does not
4943/// chain-fail the rest.
4944#[doc(hidden)]
4945#[must_use]
4946pub fn lock_permissions_mode_for_test() -> std::sync::MutexGuard<'static, ()> {
4947 use std::sync::Mutex;
4948 static GATE_LOCK: Mutex<()> = Mutex::new(());
4949 GATE_LOCK
4950 .lock()
4951 .unwrap_or_else(std::sync::PoisonError::into_inner)
4952}
4953
4954// ---------------------------------------------------------------------------
4955// Decision counters per mode (K3 — surfaced by doctor + capabilities)
4956// ---------------------------------------------------------------------------
4957
4958use std::sync::atomic::{AtomicU64, Ordering};
4959
4960/// Per-process per-mode decision counters (#1174 pm-v3.1 PR7).
4961///
4962/// Previously three sibling `static AtomicU64` items
4963/// (`DECISIONS_ENFORCE`/`_ADVISORY`/`_OFF`). Folding them into a
4964/// single struct keeps the in-memory layout identical (`#[repr(C)]`
4965/// is unnecessary — Rust's default field order is fine for the
4966/// atomic-counters-as-observability use case) while ensuring that
4967/// adding a fourth mode in the future requires a single grep-friendly
4968/// edit instead of N parallel static declarations.
4969///
4970/// `Relaxed` ordering is preserved everywhere the original three
4971/// statics used it: the counters are observability, not load-bearing
4972/// for correctness, and the inter-mode read consistency that an
4973/// `SeqCst` snapshot would buy is not exercised by any current caller
4974/// (`ai-memory doctor` + capabilities both render the snapshot as
4975/// three independent integers).
4976struct DecisionCounters {
4977 enforce: AtomicU64,
4978 advisory: AtomicU64,
4979 off: AtomicU64,
4980}
4981
4982impl DecisionCounters {
4983 const fn new() -> Self {
4984 Self {
4985 enforce: AtomicU64::new(0),
4986 advisory: AtomicU64::new(0),
4987 off: AtomicU64::new(0),
4988 }
4989 }
4990
4991 fn counter_for(&self, mode: PermissionsMode) -> &AtomicU64 {
4992 match mode {
4993 PermissionsMode::Enforce => &self.enforce,
4994 PermissionsMode::Advisory => &self.advisory,
4995 PermissionsMode::Off => &self.off,
4996 }
4997 }
4998}
4999
5000static DECISION_COUNTERS: DecisionCounters = DecisionCounters::new();
5001
5002/// Snapshot of decision counts per mode since process start. Surfaced
5003/// by `ai-memory doctor` and the capabilities `permissions` block so
5004/// operators can verify the gate is wired and observe drift between
5005/// "policies advertised" and "policies enforced".
5006#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
5007pub struct PermissionsDecisionCounts {
5008 pub enforce: u64,
5009 pub advisory: u64,
5010 pub off: u64,
5011}
5012
5013/// Increment the decision counter for `mode`. Called by the gate on
5014/// every consult. `Relaxed` is fine: the counters are observability,
5015/// not load-bearing for correctness.
5016pub fn record_permissions_decision(mode: PermissionsMode) {
5017 DECISION_COUNTERS
5018 .counter_for(mode)
5019 .fetch_add(1, Ordering::Relaxed);
5020}
5021
5022/// Snapshot the current per-mode decision counts.
5023#[must_use]
5024pub fn permissions_decision_counts() -> PermissionsDecisionCounts {
5025 PermissionsDecisionCounts {
5026 enforce: DECISION_COUNTERS.enforce.load(Ordering::Relaxed),
5027 advisory: DECISION_COUNTERS.advisory.load(Ordering::Relaxed),
5028 off: DECISION_COUNTERS.off.load(Ordering::Relaxed),
5029 }
5030}
5031
5032/// Test-only: zero the counters between scenarios so the K3 matrix
5033/// can assert exact deltas.
5034#[doc(hidden)]
5035pub fn reset_permissions_decision_counts_for_test() {
5036 DECISION_COUNTERS.enforce.store(0, Ordering::SeqCst);
5037 DECISION_COUNTERS.advisory.store(0, Ordering::SeqCst);
5038 DECISION_COUNTERS.off.store(0, Ordering::SeqCst);
5039}
5040
5041// ---------------------------------------------------------------------------
5042// Logging facility (PR-5)
5043// ---------------------------------------------------------------------------
5044
5045/// `[logging]` block in `config.toml`. Every field is `Option`; missing
5046/// fields fall back to the documented defaults.
5047#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5048pub struct LoggingConfig {
5049 /// Master toggle. Default `false`.
5050 pub enabled: Option<bool>,
5051 /// Directory for rotated logs. Default `~/.local/state/ai-memory/logs/`.
5052 pub path: Option<String>,
5053 /// Soft cap on a single rotated file (advisory — informs rotation
5054 /// configuration; the appender enforces this via the chosen
5055 /// `rotation` cadence). Default 100.
5056 pub max_size_mb: Option<u64>,
5057 /// Maximum number of rotated files retained on disk. Default 30.
5058 pub max_files: Option<usize>,
5059 /// Days of log history to keep before `ai-memory logs archive`
5060 /// would compress them. Default 90.
5061 pub retention_days: Option<u32>,
5062 /// Emit JSON lines instead of the human-readable fmt layer. Default `false`.
5063 pub structured: Option<bool>,
5064 /// Tracing level / `EnvFilter` directive. Default `"info"`.
5065 pub level: Option<String>,
5066 /// Rotation policy: `minutely | hourly | daily | never`. Default `"daily"`.
5067 pub rotation: Option<String>,
5068 /// Override the rotated-file prefix. Default `"ai-memory.log"`.
5069 pub filename_prefix: Option<String>,
5070}
5071
5072// ---------------------------------------------------------------------------
5073// Audit facility (PR-5)
5074// ---------------------------------------------------------------------------
5075
5076/// `[audit]` block in `config.toml`. Drives the hash-chained audit
5077/// trail emitted from every memory mutation call site.
5078#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5079pub struct AuditConfig {
5080 /// Master toggle. Default `false`.
5081 pub enabled: Option<bool>,
5082 /// Audit log path. Either a directory (in which case `audit.log`
5083 /// is appended) or an explicit file path. Default
5084 /// `~/.local/state/ai-memory/audit/`.
5085 pub path: Option<String>,
5086 /// Documented schema version on the wire. The binary always emits
5087 /// `audit::SCHEMA_VERSION`; this knob is reserved for forward
5088 /// compatibility and must equal the binary's emitted version
5089 /// today (validated at init).
5090 pub schema_version: Option<u32>,
5091 /// Whether to redact `memory.content` from emitted events. **The
5092 /// only supported value in v1 is `true`** — the audit schema does
5093 /// not expose a content field at all; this flag is reserved for a
5094 /// future per-namespace exception API.
5095 pub redact_content: Option<bool>,
5096 /// Whether to compute and verify the per-line hash chain. Default `true`.
5097 pub hash_chain: Option<bool>,
5098 /// Cadence in minutes for the periodic `CHECKPOINT.sig`
5099 /// attestation marker. The marker is a synthetic audit event that
5100 /// pins the chain head into the log so an attacker who truncates
5101 /// the file can't silently rewind history. Default 60. 0 disables.
5102 pub attestation_cadence_minutes: Option<u32>,
5103 /// Apply the platform-appropriate "append-only" file flag at
5104 /// startup. Best-effort defense in depth; the chain is the
5105 /// load-bearing tamper-evidence. Default `true`.
5106 pub append_only: Option<bool>,
5107 /// Retention horizon (days). `ai-memory logs purge` warns about
5108 /// deleting audit records younger than this, and `audit verify`
5109 /// surfaces gaps when retention is shorter than the chain extent.
5110 /// Default 90. Compliance presets override.
5111 pub retention_days: Option<u32>,
5112 /// Compliance presets — apply industry-standard retention /
5113 /// redaction policy on top of the base config. See
5114 /// `docs/security/audit-trail.md` §Compliance.
5115 pub compliance: Option<AuditComplianceConfig>,
5116}
5117
5118impl AuditConfig {
5119 /// Resolve the effective retention horizon after applying any
5120 /// active compliance preset. Presets win when `applied = true`;
5121 /// when multiple presets are applied the most-conservative
5122 /// (longest) retention wins so the binary never picks a value
5123 /// that violates any active policy.
5124 #[must_use]
5125 pub fn effective_retention_days(&self) -> u32 {
5126 let mut chosen = self.retention_days.unwrap_or(90);
5127 if let Some(comp) = &self.compliance {
5128 for preset in comp.applied_presets() {
5129 if let Some(d) = preset.retention_days
5130 && d > chosen
5131 {
5132 chosen = d;
5133 }
5134 }
5135 }
5136 chosen
5137 }
5138
5139 /// Resolve the effective attestation cadence — the most-frequent
5140 /// (smallest non-zero) cadence across the base config and applied
5141 /// presets so the strictest compliance rule wins.
5142 #[must_use]
5143 pub fn effective_attestation_cadence_minutes(&self) -> u32 {
5144 let base = self.attestation_cadence_minutes.unwrap_or(60);
5145 let mut chosen = base;
5146 if let Some(comp) = &self.compliance {
5147 for preset in comp.applied_presets() {
5148 if let Some(m) = preset.attestation_cadence_minutes
5149 && m > 0
5150 && (chosen == 0 || m < chosen)
5151 {
5152 chosen = m;
5153 }
5154 }
5155 }
5156 chosen
5157 }
5158}
5159
5160// ---------------------------------------------------------------------------
5161// Boot privacy controls (PR-9h, v0.6.3.1, issue #487 PR #497 req #73)
5162// ---------------------------------------------------------------------------
5163
5164/// `[boot]` block in `config.toml`. Drives the privacy kill-switch +
5165/// title-redaction behaviour of `ai-memory boot`. Both fields default
5166/// to the historical (pre-v0.6.3.1) behaviour so existing users see no
5167/// change.
5168///
5169/// Precedence for `enabled`:
5170/// `AI_MEMORY_BOOT_ENABLED=0` env var (truthy "0/false/no/off") >
5171/// `[boot] enabled` config value > compiled default `true`.
5172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5173pub struct BootConfig {
5174 /// Master toggle. Default `true`. When set to `false`, `ai-memory
5175 /// boot` exits 0 with **empty stdout AND empty stderr** — the
5176 /// privacy-sensitive escape hatch for hosts where memory titles
5177 /// must never enter CI logs. The hook injects nothing.
5178 pub enabled: Option<bool>,
5179 /// When `true`, the manifest header still appears but every
5180 /// memory row's `title` field is replaced with `<redacted>` —
5181 /// useful for compliance contexts that need an audit trail of
5182 /// "boot ran with N memories" without exposing memory subjects.
5183 /// Default `false`.
5184 pub redact_titles: Option<bool>,
5185}
5186
5187impl BootConfig {
5188 /// Resolve the effective `enabled` value with env-var precedence.
5189 /// `AI_MEMORY_BOOT_ENABLED=0/false/no/off` forces disabled;
5190 /// `=1/true/yes/on` forces enabled. Anything else falls through to
5191 /// the config file value (or the compiled default `true`).
5192 #[must_use]
5193 pub fn effective_enabled(&self) -> bool {
5194 if let Ok(v) = std::env::var("AI_MEMORY_BOOT_ENABLED") {
5195 let v = v.trim().to_ascii_lowercase();
5196 if matches!(v.as_str(), "0" | "false" | "no" | "off") {
5197 return false;
5198 }
5199 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
5200 return true;
5201 }
5202 }
5203 self.enabled.unwrap_or(true)
5204 }
5205
5206 /// Resolve the effective `redact_titles` value. Default `false`.
5207 #[must_use]
5208 pub fn effective_redact_titles(&self) -> bool {
5209 self.redact_titles.unwrap_or(false)
5210 }
5211}
5212
5213// ---------------------------------------------------------------------------
5214// MCP server tunables (v0.6.4)
5215// ---------------------------------------------------------------------------
5216
5217/// `[mcp]` block in `config.toml` — v0.6.4 addition. Today this only
5218/// carries the named tool `profile`. v0.6.4 Track D will extend with
5219/// `[mcp.allowlist]` for per-agent capability gating.
5220///
5221/// Resolution for `profile`: CLI flag > `AI_MEMORY_PROFILE` env (both
5222/// merged by clap) > this config field > compiled default `"core"`.
5223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5224pub struct McpConfig {
5225 /// Named tool profile. One of `core`, `graph`, `admin`, `power`,
5226 /// `full`, or a comma-separated custom list (e.g.,
5227 /// `core,graph,archive`). Default `core` (v0.6.4 default flip).
5228 pub profile: Option<String>,
5229
5230 /// v0.6.4-008 — per-agent capability allowlist. Maps an agent_id
5231 /// pattern to the families that agent may request via
5232 /// `memory_capabilities --include-schema family=<f>`. Patterns
5233 /// resolve to a Vec<String> (the family names). The wildcard
5234 /// pattern `"*"` is the default for agents not otherwise listed.
5235 /// When the entire allowlist is absent (`mcp.allowlist = None`),
5236 /// the gate is disabled — every caller may expand any family
5237 /// (Tier-1 single-process semantics, profile flag rules).
5238 ///
5239 /// Example config.toml:
5240 /// ```toml
5241 /// [mcp.allowlist]
5242 /// "alice" = ["core", "graph"]
5243 /// "bob" = ["full"]
5244 /// "*" = ["core"]
5245 /// ```
5246 pub allowlist: Option<std::collections::HashMap<String, Vec<String>>>,
5247
5248 /// #1254 (MED, 2026-05-25) — error-oracle posture for
5249 /// `tools/call` against a tool that exists but is not loaded
5250 /// under the active profile.
5251 ///
5252 /// Default `false` (production-secure): the daemon returns a
5253 /// minimal `"unknown tool: <name>"` regardless of whether the
5254 /// tool exists in another family. This prevents a lower-profile
5255 /// client from probing the surface of a higher-profile tool set
5256 /// (e.g. `admin` or `power` family names) by walking error
5257 /// messages.
5258 ///
5259 /// Set to `true` to restore the v0.6.4-002 helpful hint
5260 /// ("tool 'X' is in family 'Y' which is not loaded under the
5261 /// active profile. Restart with `--profile <name>` ..."). The
5262 /// hint is convenient for single-tenant dev environments where
5263 /// every operator sees the full surface anyway, but leaks
5264 /// family membership in any multi-tenant deployment.
5265 #[serde(default)]
5266 pub profile_hint_in_errors: bool,
5267}
5268
5269impl McpConfig {
5270 /// v0.6.4-008 — resolve the allowlist decision for an agent
5271 /// requesting a family.
5272 ///
5273 /// Returns:
5274 /// - `AllowlistDecision::Disabled` if the entire allowlist is
5275 /// absent (Tier-1 default — gate is off).
5276 /// - `AllowlistDecision::Allow` if a matching pattern includes
5277 /// the requested family (or `"full"`).
5278 /// - `AllowlistDecision::Deny` if a pattern matches but does
5279 /// not list the family.
5280 /// - `AllowlistDecision::Deny` if no pattern matches and there
5281 /// is no `"*"` wildcard.
5282 ///
5283 /// Pattern matching: exact match wins; otherwise the wildcard
5284 /// `"*"` is consulted. Multiple-pattern precedence follows
5285 /// longest-prefix order with stable tie-break by config order
5286 /// (since `HashMap` is unordered, we sort by key length
5287 /// descending for the comparison).
5288 #[must_use]
5289 pub fn allowlist_decision(&self, agent_id: Option<&str>, family: &str) -> AllowlistDecision {
5290 let table = match self.allowlist.as_ref() {
5291 Some(t) if !t.is_empty() => t,
5292 _ => return AllowlistDecision::Disabled,
5293 };
5294 // Tier-1: no agent_id → only the wildcard rule applies. Same
5295 // restrictive default as for an unknown agent.
5296 let aid = agent_id.unwrap_or("");
5297 // Exact match first.
5298 if let Some(families) = table.get(aid) {
5299 return decide(families, family);
5300 }
5301 // Longest-prefix match next (excluding `"*"`).
5302 let mut keys: Vec<&String> = table
5303 .keys()
5304 .filter(|k| k.as_str() != "*" && aid.starts_with(k.as_str()))
5305 .collect();
5306 keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
5307 if let Some(k) = keys.first() {
5308 if let Some(families) = table.get(*k) {
5309 return decide(families, family);
5310 }
5311 }
5312 // Wildcard fallback.
5313 if let Some(families) = table.get("*") {
5314 return decide(families, family);
5315 }
5316 AllowlistDecision::Deny
5317 }
5318}
5319
5320fn decide(families: &[String], requested: &str) -> AllowlistDecision {
5321 if families.iter().any(|f| f == "full" || f == requested) {
5322 AllowlistDecision::Allow
5323 } else {
5324 AllowlistDecision::Deny
5325 }
5326}
5327
5328/// v0.6.4-008 — outcome of an allowlist check.
5329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5330pub enum AllowlistDecision {
5331 /// Allowlist is not configured; no gate.
5332 Disabled,
5333 /// Pattern match grants access to the requested family.
5334 Allow,
5335 /// Pattern match denies (or no pattern matched).
5336 Deny,
5337}
5338
5339#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5340pub struct AuditComplianceConfig {
5341 pub soc2: Option<CompliancePreset>,
5342 pub hipaa: Option<CompliancePreset>,
5343 pub gdpr: Option<CompliancePreset>,
5344 pub fedramp: Option<CompliancePreset>,
5345}
5346
5347impl AuditComplianceConfig {
5348 /// Iterate over every preset whose `applied = true`.
5349 pub fn applied_presets(&self) -> impl Iterator<Item = &CompliancePreset> {
5350 [
5351 self.soc2.as_ref(),
5352 self.hipaa.as_ref(),
5353 self.gdpr.as_ref(),
5354 self.fedramp.as_ref(),
5355 ]
5356 .into_iter()
5357 .flatten()
5358 .filter(|p| p.applied.unwrap_or(false))
5359 }
5360}
5361
5362#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5363pub struct CompliancePreset {
5364 pub applied: Option<bool>,
5365 pub retention_days: Option<u32>,
5366 pub redact_content: Option<bool>,
5367 pub attestation_cadence_minutes: Option<u32>,
5368 /// Reserved for compliance contexts that mandate at-rest crypto.
5369 /// HIPAA preset surfaces this so operators can pair audit with
5370 /// `--features sqlcipher` for end-to-end at-rest encryption.
5371 pub encrypt_at_rest: Option<bool>,
5372 /// GDPR-style actor pseudonymization toggle. Reserved for v0.7+.
5373 pub pseudonymize_actors: Option<bool>,
5374}
5375
5376/// Identity-resolution configuration (Task 1.2 follow-up #198).
5377///
5378/// Lets operators opt out of the default `host:<hostname>:pid-<pid>-<uuid8>`
5379/// fallback when no explicit `agent_id` is supplied. `anonymize_default = true`
5380/// swaps the hostname-revealing default for `anonymous:pid-<pid>-<uuid8>`,
5381/// matching what the `AI_MEMORY_ANONYMIZE=1` env var does ephemerally.
5382#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5383pub struct IdentityConfig {
5384 /// When true, the "no flag, no env, no MCP clientInfo" fallback uses
5385 /// `anonymous:pid-<pid>-<uuid8>` instead of the hostname-revealing
5386 /// `host:<hostname>:pid-<pid>-<uuid8>`. Default false.
5387 #[serde(default)]
5388 pub anonymize_default: bool,
5389}
5390
5391/// v0.7.0 (issue #518) — parse a duration string of the form
5392/// `"<integer><unit>"` into a `chrono::Duration`. Supported units:
5393/// `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks).
5394/// Whitespace and case are tolerated. Returns `None` on malformed
5395/// input — the caller falls through to "no since filter applied".
5396///
5397/// Intentionally a small bespoke parser rather than a `humantime`
5398/// dependency: the surface we need is tiny (4-5 units) and operators
5399/// expect the same shape they already type into `--since` flags.
5400#[must_use]
5401pub fn parse_duration_string(s: &str) -> Option<chrono::Duration> {
5402 let trimmed = s.trim().to_ascii_lowercase();
5403 if trimmed.is_empty() {
5404 return None;
5405 }
5406 let (num_part, unit_part) = trimmed.split_at(
5407 trimmed
5408 .find(|c: char| !c.is_ascii_digit())
5409 .unwrap_or(trimmed.len()),
5410 );
5411 let n: i64 = num_part.parse().ok()?;
5412 if n < 0 {
5413 return None;
5414 }
5415 match unit_part.trim() {
5416 "s" | "sec" | "secs" | "second" | "seconds" => Some(chrono::Duration::seconds(n)),
5417 "m" | "min" | "mins" | "minute" | "minutes" => Some(chrono::Duration::minutes(n)),
5418 "h" | "hr" | "hrs" | "hour" | "hours" => Some(chrono::Duration::hours(n)),
5419 "d" | "day" | "days" => Some(chrono::Duration::days(n)),
5420 "w" | "wk" | "wks" | "week" | "weeks" => Some(chrono::Duration::weeks(n)),
5421 _ => None,
5422 }
5423}
5424
5425/// Expand a leading `~` or `~/` in a path string to `$HOME`. POSIX-style.
5426/// `~user/...` is not supported (rare in our deployment surface, and supporting
5427/// it requires `getpwnam` — out of scope for the #507 fix). When `$HOME` is
5428/// unset (no-home environments like some CI containers), the tilde is left
5429/// untouched so the existing failure mode (path not found) is preserved
5430/// rather than silently rewriting to an empty prefix.
5431// ---------------------------------------------------------------------------
5432// Resolver helpers (#1146)
5433// ---------------------------------------------------------------------------
5434
5435/// Backend-specific default model identifier. Used by
5436/// [`AppConfig::resolve_llm`] when no model is configured at any
5437/// precedence layer.
5438fn backend_default_model(backend: &str) -> &'static str {
5439 match backend {
5440 "xai" => "grok-4.3",
5441 "openai" => "gpt-5",
5442 "anthropic" => "claude-opus-4.7",
5443 "gemini" => "gemini-2.0-flash",
5444 "deepseek" => "deepseek-chat",
5445 "kimi" | "moonshot" => "moonshot-v1-8k",
5446 "qwen" | "dashscope" => "qwen-max",
5447 "mistral" => "mistral-large-latest",
5448 "groq" => "llama-3.3-70b-versatile",
5449 "together" => "meta-llama/Llama-3.3-70B-Instruct-Turbo",
5450 "cerebras" => "llama-3.3-70b",
5451 "openrouter" => "openai/gpt-5",
5452 "fireworks" => "accounts/fireworks/models/llama-v3p3-70b-instruct",
5453 "lmstudio" => "local-model",
5454 // ollama / openai-compatible / any unknown alias → legacy default.
5455 _ => "gemma3:4b",
5456 }
5457}
5458
5459/// Backend-specific default base URL. Used by
5460/// [`AppConfig::resolve_llm`] when no base_url is configured at any
5461/// precedence layer. `openai-compatible` returns the empty string (the
5462/// resolver does not validate this — surface plumbing surfaces the
5463/// misconfiguration via the reachability probe in `ai-memory doctor`).
5464fn backend_default_base_url(backend: &str) -> &'static str {
5465 match backend {
5466 "openai" => "https://api.openai.com/v1",
5467 "xai" => "https://api.x.ai/v1",
5468 "anthropic" => "https://api.anthropic.com/v1",
5469 "gemini" => "https://generativelanguage.googleapis.com/v1beta/openai",
5470 "deepseek" => "https://api.deepseek.com/v1",
5471 "kimi" | "moonshot" => "https://api.moonshot.cn/v1",
5472 "qwen" | "dashscope" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
5473 "mistral" => "https://api.mistral.ai/v1",
5474 "groq" => "https://api.groq.com/openai/v1",
5475 "together" => "https://api.together.xyz/v1",
5476 "cerebras" => "https://api.cerebras.ai/v1",
5477 "openrouter" => "https://openrouter.ai/api/v1",
5478 "fireworks" => "https://api.fireworks.ai/inference/v1",
5479 "lmstudio" => "http://localhost:1234/v1",
5480 // ollama / openai-compatible / unknown → localhost ollama.
5481 _ => "http://localhost:11434",
5482 }
5483}
5484
5485/// Per-alias environment variable fallback chain for the API key.
5486/// Mirrors `crate::llm::alias_api_key_env_vars` (kept duplicated to
5487/// avoid a circular dependency between the resolver and the LLM
5488/// client; both lists must stay in sync — pinned by a test in
5489/// commit 12/13).
5490fn alias_api_key_env_vars_for_resolver(alias: &str) -> &'static [&'static str] {
5491 match alias {
5492 "openai" => &["OPENAI_API_KEY"],
5493 "xai" => &["XAI_API_KEY"],
5494 "anthropic" => &["ANTHROPIC_API_KEY"],
5495 "gemini" => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
5496 "deepseek" => &["DEEPSEEK_API_KEY"],
5497 "kimi" | "moonshot" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
5498 "qwen" | "dashscope" => &["DASHSCOPE_API_KEY", "QWEN_API_KEY"],
5499 "mistral" => &["MISTRAL_API_KEY"],
5500 "groq" => &["GROQ_API_KEY"],
5501 "together" => &["TOGETHER_API_KEY"],
5502 "cerebras" => &["CEREBRAS_API_KEY"],
5503 "openrouter" => &["OPENROUTER_API_KEY"],
5504 "fireworks" => &["FIREWORKS_API_KEY"],
5505 _ => &[],
5506 }
5507}
5508
5509/// Canonicalise legacy embedding-model aliases to the HF-id form. Lets
5510/// existing config.toml files with `embedding_model = "nomic_embed_v15"`
5511/// continue to work while the resolver returns the canonical id used
5512/// throughout the substrate.
5513fn canonicalise_embedding_model(raw: String) -> String {
5514 match raw.trim() {
5515 "nomic_embed_v15" => "nomic-embed-text-v1.5".to_string(),
5516 "mini_lm_l6_v2" => "sentence-transformers/all-MiniLM-L6-v2".to_string(),
5517 _ => raw,
5518 }
5519}
5520
5521/// v0.7.x (issue #1169) — known canonical embedding-model id → vector
5522/// dim mappings.
5523///
5524/// Used by [`canonical_embedding_dim`] (resolver-side) and
5525/// [`build_capability_models`] (capabilities-surface side) so the
5526/// reported `embedding_dim` reflects the live model the embedder
5527/// produces vectors of, NOT the compiled tier preset's hardcoded dim.
5528/// Pre-#1169 the dim was sourced only from the 2-family
5529/// [`EmbeddingModel`] enum — picking any other model id (e.g. Ollama
5530/// `bge-large-en`) silently fell back to the tier preset's wrong dim.
5531///
5532/// New entries land here when an operator adopts a model not yet
5533/// covered. Unknown models resolve to `None`
5534/// ([`canonical_embedding_dim`] return), which causes
5535/// [`build_capability_models`] to fall back to the tier preset's dim
5536/// — preserving the pre-#1169 behaviour for unrecognised ids and
5537/// avoiding the silent-wrong-dim trap for recognised ones.
5538///
5539/// Match keys are case-insensitive (lookup uses
5540/// `eq_ignore_ascii_case`) and span the canonical HF id, the
5541/// unprefixed shortname, and the common Ollama tag where they
5542/// diverge. Matches whatever the operator actually wrote in
5543/// `[embeddings].model` post-`canonicalise_embedding_model`.
5544pub const KNOWN_EMBEDDING_DIMS: &[(&str, u32)] = &[
5545 // nomic-ai (default for the v0.7.0 autonomous tier)
5546 ("nomic-embed-text-v1.5", 768),
5547 ("nomic-embed-text", 768),
5548 ("nomic-ai/nomic-embed-text-v1.5", 768),
5549 // sentence-transformers / MiniLM family
5550 ("sentence-transformers/all-MiniLM-L6-v2", 384),
5551 ("all-MiniLM-L6-v2", 384),
5552 ("all-minilm", 384),
5553 ("all-minilm:l6-v2", 384),
5554 // BAAI BGE family (common Ollama-side operator picks — the #1169
5555 // repro example was bge-large-en)
5556 ("bge-large-en", 1024),
5557 ("bge-large-en-v1.5", 1024),
5558 ("baai/bge-large-en-v1.5", 1024),
5559 ("bge-base-en", 768),
5560 ("bge-base-en-v1.5", 768),
5561 ("baai/bge-base-en-v1.5", 768),
5562 ("bge-small-en", 384),
5563 ("bge-small-en-v1.5", 384),
5564 ("baai/bge-small-en-v1.5", 384),
5565 ("bge-m3", 1024),
5566 ("baai/bge-m3", 1024),
5567 // Mixed Bread AI
5568 ("mxbai-embed-large", 1024),
5569 ("mxbai-embed-large-v1", 1024),
5570 ("mixedbread-ai/mxbai-embed-large-v1", 1024),
5571 // OpenAI text-embedding family
5572 ("text-embedding-3-small", 1536),
5573 ("text-embedding-3-large", 3072),
5574 ("text-embedding-ada-002", 1536),
5575 // Google embedding
5576 ("embedding-001", 768),
5577 ("text-embedding-004", 768),
5578 ("google/gemini-embedding-2", 3072),
5579 ("gemini-embedding-2", 3072),
5580 // IBM Granite (#1598 — common self-hosted TEI/vLLM pick)
5581 ("ibm-granite/granite-embedding-125m-english", 768),
5582 ("granite-embedding", 768),
5583 // Snowflake Arctic
5584 ("snowflake-arctic-embed", 1024),
5585 ("snowflake-arctic-embed:l", 1024),
5586 ("snowflake-arctic-embed-l", 1024),
5587 ("snowflake-arctic-embed:m", 768),
5588 ("snowflake-arctic-embed:s", 384),
5589];
5590
5591/// v0.7.x (issue #1169) — look up the vector dim for a canonical
5592/// embedding model id. Returns `None` when the model is not in the
5593/// [`KNOWN_EMBEDDING_DIMS`] table; callers fall back to the tier
5594/// preset (preserving pre-#1169 behaviour for unrecognised ids).
5595///
5596/// The lookup is case-insensitive and ignores leading/trailing
5597/// whitespace. Matches the canonicalised form
5598/// ([`canonicalise_embedding_model`] runs first), so the table
5599/// keys are the HF-id / Ollama tag forms operators actually set in
5600/// `[embeddings].model` after legacy-alias canonicalisation.
5601#[must_use]
5602pub fn canonical_embedding_dim(model: &str) -> Option<u32> {
5603 let needle = model.trim();
5604 if needle.is_empty() {
5605 return None;
5606 }
5607 KNOWN_EMBEDDING_DIMS
5608 .iter()
5609 .find(|(id, _)| id.eq_ignore_ascii_case(needle))
5610 .map(|(_, dim)| *dim)
5611}
5612
5613/// Resolve the API key + provenance tag for the configured backend.
5614///
5615/// Precedence:
5616/// 1. `AI_MEMORY_LLM_API_KEY` process env → `KeySource::ProcessEnv`
5617/// 2. Per-vendor process env-var fallback (e.g. `XAI_API_KEY`)
5618/// → `KeySource::AliasFallback(name)`
5619/// 3. `[llm].api_key_env` → `KeySource::ConfigEnvVar(name)`
5620/// 4. `[llm].api_key_file` → `KeySource::ConfigFile(path)`
5621/// 5. None resolved → `KeySource::None` (correct for `backend =
5622/// "ollama"`; a misconfiguration for OpenAI-compatible vendors —
5623/// surfaced by the reachability probe).
5624///
5625/// #1598 — thin delegate over [`resolve_api_key_ladder`] (the same
5626/// ladder serves the `[embeddings]` section via
5627/// [`resolve_embed_api_key`]).
5628fn resolve_api_key(backend: &str, llm: Option<&LlmSection>) -> (Option<String>, KeySource) {
5629 resolve_api_key_ladder(
5630 ENV_LLM_API_KEY,
5631 backend,
5632 llm.and_then(|l| l.api_key_env.as_deref()),
5633 llm.and_then(|l| l.api_key_file.as_deref()),
5634 "llm",
5635 )
5636}
5637
5638/// #1598 — resolve the EMBEDDING API key + provenance tag for the
5639/// configured embedding backend. Mirrors [`resolve_api_key`] with the
5640/// `[embeddings]`-section sources:
5641///
5642/// 1. `AI_MEMORY_EMBED_API_KEY` process env → `KeySource::ProcessEnv`
5643/// 2. Per-vendor process env-var fallback (e.g. `OPENROUTER_API_KEY`)
5644/// → `KeySource::AliasFallback(name)`
5645/// 3. `[embeddings].api_key_env` → `KeySource::ConfigEnvVar(name)`
5646/// 4. `[embeddings].api_key_file` (0400 enforced)
5647/// → `KeySource::ConfigFile(path)`
5648/// 5. None resolved → `KeySource::None` (correct for `backend =
5649/// "ollama"` and for keyless self-hosted OpenAI-compatible
5650/// endpoints such as HF TEI / vLLM).
5651fn resolve_embed_api_key(
5652 backend: &str,
5653 embeddings: Option<&EmbeddingsSection>,
5654) -> (Option<String>, KeySource) {
5655 resolve_api_key_ladder(
5656 ENV_EMBED_API_KEY,
5657 backend,
5658 embeddings.and_then(|e| e.api_key_env.as_deref()),
5659 embeddings.and_then(|e| e.api_key_file.as_deref()),
5660 "embeddings",
5661 )
5662}
5663
5664/// #1598 — true when the embedding backend speaks an API wire shape
5665/// (OpenAI-compatible `/embeddings` + Bearer auth) rather than the
5666/// local Ollama-native `/api/embed` shape. `"ollama"` is the ONLY
5667/// non-API backend; every #1067 alias and the generic
5668/// `openai-compatible` escape hatch classify as API backends. Sits
5669/// next to [`alias_api_key_env_vars_for_resolver`] /
5670/// [`backend_default_base_url`] — the alias machinery it complements.
5671#[must_use]
5672pub fn is_api_embed_backend(backend: &str) -> bool {
5673 !backend
5674 .trim()
5675 .eq_ignore_ascii_case(crate::llm::BACKEND_OLLAMA)
5676}
5677
5678/// Shared API-key resolution ladder for the `[llm]` and `[embeddings]`
5679/// sections (#1146 / #1598). `primary_env` is the section's dedicated
5680/// `AI_MEMORY_*_API_KEY` env var; `section` is the bare section name
5681/// (`"llm"` / `"embeddings"`) used in provenance / error strings.
5682///
5683/// File reads enforce mode 0400 (via [`enforce_api_key_file_perms`])
5684/// and surface failures as `KeySource::Error(reason)` so the daemon
5685/// can boot and report the problem through `ai-memory doctor` rather
5686/// than failing at config load.
5687fn resolve_api_key_ladder(
5688 primary_env: &str,
5689 backend: &str,
5690 api_key_env: Option<&str>,
5691 api_key_file: Option<&str>,
5692 section: &str,
5693) -> (Option<String>, KeySource) {
5694 // 1. Process env (highest).
5695 if let Some(k) = std::env::var(primary_env)
5696 .ok()
5697 .filter(|s| !s.trim().is_empty())
5698 {
5699 return (Some(k), KeySource::ProcessEnv);
5700 }
5701
5702 // 2. Per-vendor alias fallback.
5703 for name in alias_api_key_env_vars_for_resolver(backend) {
5704 if let Some(k) = std::env::var(name).ok().filter(|s| !s.trim().is_empty()) {
5705 return (Some(k), KeySource::AliasFallback((*name).to_string()));
5706 }
5707 }
5708
5709 // 3. config-pointed env var.
5710 if let Some(name) = api_key_env.filter(|s| !s.trim().is_empty()) {
5711 return match std::env::var(name) {
5712 Ok(v) if !v.trim().is_empty() => (Some(v), KeySource::ConfigEnvVar(name.to_string())),
5713 Ok(_) => (
5714 None,
5715 KeySource::Error(format!(
5716 "[{section}].api_key_env = {name:?} resolves to an empty env var"
5717 )),
5718 ),
5719 Err(_) => (
5720 None,
5721 KeySource::Error(format!(
5722 "[{section}].api_key_env = {name:?} is not set in the process env"
5723 )),
5724 ),
5725 };
5726 }
5727
5728 // 4. config-pointed file.
5729 if let Some(raw_path) = api_key_file.filter(|s| !s.trim().is_empty()) {
5730 let field = format!("[{section}].api_key_file");
5731 let path = expand_tilde(raw_path);
5732 let path_display = path.display().to_string();
5733
5734 // Mode 0400 enforcement (#1055-style escape hatch).
5735 if let Err(reason) = enforce_api_key_file_perms(&path, &field) {
5736 return (None, KeySource::Error(reason));
5737 }
5738
5739 return match std::fs::read_to_string(&path) {
5740 Ok(contents) => {
5741 let key = contents.lines().next().unwrap_or("").trim().to_string();
5742 if key.is_empty() {
5743 (
5744 None,
5745 KeySource::Error(format!("{field} = {path_display:?} is empty")),
5746 )
5747 } else {
5748 (Some(key), KeySource::ConfigFile(path_display))
5749 }
5750 }
5751 Err(e) => (
5752 None,
5753 KeySource::Error(format!("{field} = {path_display:?} could not be read: {e}")),
5754 ),
5755 };
5756 }
5757
5758 (None, KeySource::None)
5759}
5760
5761/// v0.7.x (#1146) — enforce mode 0400 (or stricter) on the file
5762/// referenced by `[llm].api_key_file` / `[embeddings].api_key_file`
5763/// (#1598; `field` names the rejecting config field in error text).
5764/// The check mirrors the existing `AI_MEMORY_DB_PASSPHRASE_FILE`
5765/// enforcement (issue #1055): any bits set in `mode & 0o077` (group /
5766/// world readable / executable) cause the daemon to refuse the file,
5767/// unless the operator opts out via
5768/// `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1`.
5769///
5770/// On non-Unix platforms (the `staticlib` mobile target, future
5771/// Windows builds) the check is a no-op — the perm bits are not
5772/// expressible on those platforms.
5773fn enforce_api_key_file_perms(path: &Path, field: &str) -> Result<(), String> {
5774 #[cfg(unix)]
5775 {
5776 use std::os::unix::fs::PermissionsExt;
5777 let metadata = std::fs::metadata(path).map_err(|e| {
5778 format!(
5779 "{field} = {:?} could not be stat'd for perms check: {e}",
5780 path.display(),
5781 )
5782 })?;
5783 let mode = metadata.permissions().mode();
5784 if mode & 0o077 != 0 {
5785 // Allow lax perms only when the operator explicitly opts in
5786 // (mirroring #1055 for AI_MEMORY_DB_PASSPHRASE_FILE).
5787 let opt_in = std::env::var("AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS")
5788 .ok()
5789 .is_some_and(|s| {
5790 let t = s.trim().to_ascii_lowercase();
5791 matches!(t.as_str(), "1" | "true" | "yes" | "on")
5792 });
5793 if !opt_in {
5794 return Err(format!(
5795 "{field} = {:?} has lax permissions \
5796 (mode = {:o}; expected 0400 or stricter). Run \
5797 `chmod 0400 {}` to fix, or set \
5798 `AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1` to \
5799 bypass (NOT recommended for production).",
5800 path.display(),
5801 mode & 0o777,
5802 path.display()
5803 ));
5804 }
5805 tracing::warn!(
5806 "{field} = {:?} has lax permissions (mode = {:o}); \
5807 accepted because AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS=1",
5808 path.display(),
5809 mode & 0o777
5810 );
5811 }
5812 }
5813 #[cfg(not(unix))]
5814 {
5815 // Permission bits do not apply on non-Unix platforms.
5816 let _ = (path, field);
5817 }
5818 Ok(())
5819}
5820
5821fn expand_tilde(s: &str) -> PathBuf {
5822 if s == "~" {
5823 return std::env::var("HOME").map_or_else(|_| PathBuf::from(s), PathBuf::from);
5824 }
5825 if let Some(rest) = s.strip_prefix("~/") {
5826 return std::env::var("HOME")
5827 .map_or_else(|_| PathBuf::from(s), |h| PathBuf::from(h).join(rest));
5828 }
5829 PathBuf::from(s)
5830}
5831
5832impl AppConfig {
5833 /// Returns the config file path: `~/.config/ai-memory/config.toml`
5834 pub fn config_path() -> Option<PathBuf> {
5835 let home = std::env::var("HOME").ok()?;
5836 Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
5837 }
5838
5839 /// Load config from disk. Returns `AppConfig::default()` if file is missing.
5840 /// Set `AI_MEMORY_NO_CONFIG=1` to skip config loading (used by integration tests).
5841 pub fn load() -> Self {
5842 if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
5843 return Self::default();
5844 }
5845 let Some(path) = Self::config_path() else {
5846 return Self::default();
5847 };
5848 Self::load_from(&path)
5849 }
5850
5851 /// Load config from a specific path.
5852 pub fn load_from(path: &Path) -> Self {
5853 match std::fs::read_to_string(path) {
5854 Ok(contents) => {
5855 // L1 fix (v0.7.0): warn on unknown top-level keys.
5856 // `serde(deny_unknown_fields)` would be a breaking change for
5857 // operators carrying forward-compat config snippets, so we
5858 // instead parse the document twice: once as a generic
5859 // `toml::Value` to enumerate every top-level key, and once
5860 // into `AppConfig` as before. Any top-level key that is not
5861 // part of the expected `AppConfig` field set is reported via
5862 // `tracing::warn!` and otherwise silently ignored — load
5863 // continues to succeed so a typo or stale Plan C section
5864 // (`[memory]`, `[autonomous]`, `[governance]`, `[federation]`)
5865 // can no longer silently neutralise an operator's intent.
5866 Self::warn_unknown_top_level_keys(path, &contents);
5867 match toml::from_str::<Self>(&contents) {
5868 Ok(cfg) => match cfg.validate_secret_handling() {
5869 Ok(()) => {
5870 eprintln!("ai-memory: loaded config from {}", path.display());
5871 cfg.warn_legacy_schema_drift(path);
5872 cfg
5873 }
5874 Err(reason) => {
5875 eprintln!(
5876 "ai-memory: config rejected ({}): {}\n\
5877 ai-memory: falling back to default config — \
5878 fix the issue and restart. \
5879 See https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
5880 path.display(),
5881 reason
5882 );
5883 Self::default()
5884 }
5885 },
5886 Err(e) => {
5887 eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
5888 Self::default()
5889 }
5890 }
5891 }
5892 Err(_) => Self::default(),
5893 }
5894 }
5895
5896 /// v0.7.x (#1146) — emit a one-shot deprecation WARN to stderr
5897 /// when the loaded config carries legacy v1 flat fields that have
5898 /// been superseded by the sectioned v2 schema.
5899 ///
5900 /// Two posture WARNs:
5901 ///
5902 /// - **Legacy-only** (no `schema_version` OR `schema_version = 1`,
5903 /// AND any of `llm_model`, `ollama_url`, `embed_url`,
5904 /// `embedding_model`, `cross_encoder`, `default_namespace`,
5905 /// `archive_on_gc`, `archive_max_days`, `max_memory_mb`,
5906 /// `auto_tag_model` set): operator running pre-#1146 config
5907 /// shape — point them at `ai-memory config migrate`.
5908 ///
5909 /// - **Drift** (`schema_version >= 2` AND any legacy field set):
5910 /// operator has migrated but left legacy fields in place —
5911 /// legacy fields are ignored under v2, point them at
5912 /// `ai-memory config migrate` to clean up the dead weight.
5913 ///
5914 /// The WARN is gated by [`std::sync::Once`] so re-loading the
5915 /// config in the same process (e.g. tests that call
5916 /// [`AppConfig::load_from`] in a loop) does not spam stderr.
5917 ///
5918 /// DOC-6 (FX-C4-batch2, 2026-05-26): the v2 sectioned schema
5919 /// resolution path intentionally reads the legacy fields to
5920 /// emit the warn — the `#[allow(deprecated)]` is scoped here
5921 /// so the WARN site (the only thing that legitimately TOUCHES
5922 /// the legacy fields post-#1146) doesn't cascade pedantic
5923 /// errors. External consumers writing `let cfg: AppConfig =
5924 /// ...; cfg.llm_model` still get the compile-time deprecation
5925 /// warning.
5926 #[allow(deprecated)]
5927 fn warn_legacy_schema_drift(&self, path: &Path) {
5928 use std::sync::Once;
5929 static WARN_ONCE: Once = Once::new();
5930
5931 let has_legacy = self.llm_model.is_some()
5932 || self.ollama_url.is_some()
5933 || self.embed_url.is_some()
5934 || self.embedding_model.is_some()
5935 || self.cross_encoder.is_some()
5936 || self.default_namespace.is_some()
5937 || self.archive_on_gc.is_some()
5938 || self.archive_max_days.is_some()
5939 || self.max_memory_mb.is_some()
5940 || self.auto_tag_model.is_some();
5941
5942 if !has_legacy {
5943 return;
5944 }
5945
5946 let v2 = matches!(self.schema_version, Some(v) if v >= 2);
5947
5948 WARN_ONCE.call_once(|| {
5949 if v2 {
5950 eprintln!(
5951 "ai-memory: WARN — schema_version = {:?} but legacy v1 fields \
5952 are still present in {} (llm_model / ollama_url / embed_url / \
5953 embedding_model / cross_encoder / default_namespace / \
5954 archive_on_gc / archive_max_days / max_memory_mb / \
5955 auto_tag_model). Under v2 the legacy fields are IGNORED in \
5956 favor of [llm] / [embeddings] / [reranker] / [storage] \
5957 sections. Run `ai-memory config migrate` to remove them.",
5958 self.schema_version,
5959 path.display(),
5960 );
5961 } else {
5962 eprintln!(
5963 "ai-memory: WARN — legacy v1 flat-field configuration shape \
5964 detected in {}. The [llm] / [embeddings] / [reranker] / \
5965 [storage] sectioned schema (v2) is the canonical shape; \
5966 legacy fields continue to work in v0.7.x but will be \
5967 removed in v0.8.0. Run `ai-memory config migrate` to \
5968 upgrade in place (a timestamped .bak is written). See \
5969 https://github.com/alphaonedev/ai-memory-mcp/issues/1146",
5970 path.display(),
5971 );
5972 }
5973 });
5974 }
5975
5976 /// v0.7.x (#1146) — validate secret-handling discipline in the
5977 /// `[llm]` (and `[llm.auto_tag]`) sections after parse. Three
5978 /// rejections fire at load time so misconfigurations are loud
5979 /// rather than silent:
5980 ///
5981 /// 1. Inline `api_key = "<literal>"` in `[llm]`. Operators MUST
5982 /// use `api_key_env = "<ENV_VAR_NAME>"` or `api_key_file =
5983 /// "/path/to/key"` instead. Closes the v0.6.x posture where
5984 /// inline secrets in `~/.config/ai-memory/config.toml` were
5985 /// silently accepted even though the file is typically
5986 /// world-readable.
5987 ///
5988 /// 2. Both `api_key_env` and `api_key_file` set on `[llm]`.
5989 /// Mutually exclusive — operator must pick one.
5990 ///
5991 /// 3. Both `api_key_env` and `api_key_file` set on
5992 /// `[llm.auto_tag]`. Same mutex.
5993 ///
5994 /// 4. (#1598) Inline `api_key = "<literal>"` in `[embeddings]` —
5995 /// same posture as rejection 1.
5996 ///
5997 /// 5. (#1598) Both `api_key_env` and `api_key_file` set on
5998 /// `[embeddings]`. Same mutex as rejection 2.
5999 ///
6000 /// On any rejection, [`Self::load_from`] surfaces the message to
6001 /// stderr and falls back to [`Self::default`] so the daemon boots
6002 /// without the misconfigured secret rather than refusing to start
6003 /// entirely.
6004 fn validate_secret_handling(&self) -> Result<(), String> {
6005 if let Some(llm) = &self.llm {
6006 // Rejection 1 — inline api_key literal.
6007 if llm.api_key.is_some() {
6008 return Err("inline `api_key = \"<literal>\"` in [llm] is forbidden — \
6009 use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
6010 process env var, or `api_key_file = \"/path/to/key\"` to \
6011 reference a file (mode 0400 enforced). Inline secrets in \
6012 config.toml (typically world-readable) are a credential \
6013 leak."
6014 .to_string());
6015 }
6016 // Rejection 2 — env vs file mutex.
6017 if llm.api_key_env.is_some() && llm.api_key_file.is_some() {
6018 return Err("[llm].api_key_env and [llm].api_key_file are mutually \
6019 exclusive — set exactly one (or neither, to fall back \
6020 to the per-vendor env-var chain)."
6021 .to_string());
6022 }
6023 // Rejection 3 — auto_tag env vs file mutex.
6024 if let Some(auto_tag) = &llm.auto_tag {
6025 if auto_tag.api_key_env.is_some() && auto_tag.api_key_file.is_some() {
6026 return Err("[llm.auto_tag].api_key_env and \
6027 [llm.auto_tag].api_key_file are mutually exclusive."
6028 .to_string());
6029 }
6030 }
6031 }
6032 if let Some(embeddings) = &self.embeddings {
6033 // #1598 Rejection 4 — inline [embeddings].api_key literal
6034 // (mirrors the [llm] rejection above).
6035 if embeddings.api_key.is_some() {
6036 return Err(
6037 "inline `api_key = \"<literal>\"` in [embeddings] is forbidden — \
6038 use `api_key_env = \"<ENV_VAR_NAME>\"` to reference a \
6039 process env var, or `api_key_file = \"/path/to/key\"` to \
6040 reference a file (mode 0400 enforced). Inline secrets in \
6041 config.toml (typically world-readable) are a credential \
6042 leak."
6043 .to_string(),
6044 );
6045 }
6046 // #1598 Rejection 5 — [embeddings] env vs file mutex.
6047 if embeddings.api_key_env.is_some() && embeddings.api_key_file.is_some() {
6048 return Err(
6049 "[embeddings].api_key_env and [embeddings].api_key_file are \
6050 mutually exclusive — set exactly one (or neither, to fall \
6051 back to the per-vendor env-var chain)."
6052 .to_string(),
6053 );
6054 }
6055 }
6056 Ok(())
6057 }
6058
6059 /// L1 fix (v0.7.0): enumerate top-level keys in `contents` and emit a
6060 /// `tracing::warn!` for every key that is not a recognised `AppConfig`
6061 /// field. Malformed TOML is silently skipped here — the existing
6062 /// `toml::from_str::<AppConfig>` parse in `load_from` will surface the
6063 /// real parse error to the operator on the next line.
6064 fn warn_unknown_top_level_keys(path: &Path, contents: &str) {
6065 // Canonical list of `AppConfig` top-level fields. Keep in sync with
6066 // the struct definition above; verified verbatim against the v0.7.0
6067 // L1 spec.
6068 const EXPECTED_KEYS: &[&str] = &[
6069 "tier",
6070 "db",
6071 config_keys::OLLAMA_URL,
6072 "embed_url",
6073 config_keys::EMBEDDING_MODEL,
6074 "llm_model",
6075 config_keys::AUTO_TAG_MODEL,
6076 config_keys::CROSS_ENCODER,
6077 config_keys::DEFAULT_NAMESPACE,
6078 config_keys::MAX_MEMORY_MB,
6079 "ttl",
6080 config_keys::ARCHIVE_ON_GC,
6081 "api_key",
6082 config_keys::ARCHIVE_MAX_DAYS,
6083 "identity",
6084 "scoring",
6085 "autonomous_hooks",
6086 "logging",
6087 "audit",
6088 "boot",
6089 "mcp",
6090 "permissions",
6091 "transcripts",
6092 "hooks",
6093 "subscriptions",
6094 "postgres_statement_timeout_secs",
6095 "postgres_pool_max_connections",
6096 "postgres_pool_min_connections",
6097 "postgres_acquire_timeout_secs",
6098 "request_timeout_secs",
6099 "llm_call_timeout_secs",
6100 "verify",
6101 "mcp_federation_forward_url",
6102 "agents",
6103 "governance",
6104 "confidence",
6105 "admin",
6106 // v0.7.x (#1146) — enterprise configuration sections.
6107 "schema_version",
6108 "llm",
6109 config_keys::SECTION_EMBEDDINGS,
6110 "reranker",
6111 "curator",
6112 "storage",
6113 "limits",
6114 ];
6115
6116 let value: toml::Value = match toml::from_str(contents) {
6117 Ok(v) => v,
6118 // Malformed TOML — defer to the strongly-typed parse in the
6119 // caller, which produces the operator-facing error message.
6120 Err(_) => return,
6121 };
6122
6123 let Some(table) = value.as_table() else {
6124 return;
6125 };
6126
6127 let expected_list = EXPECTED_KEYS.join(", ");
6128 for key in table.keys() {
6129 if !EXPECTED_KEYS.contains(&key.as_str()) {
6130 tracing::warn!(
6131 "[config] unknown key '{key}' in {path} — top-level AppConfig fields are: {expected_keys}. This key is silently ignored (no behavior change).",
6132 key = key,
6133 path = path.display(),
6134 expected_keys = expected_list,
6135 );
6136 }
6137 }
6138 }
6139
6140 /// v0.7.0 K3 — resolve the effective [`PermissionsMode`] consulted
6141 /// by [`crate::db::enforce_governance`].
6142 ///
6143 /// Resolution order:
6144 /// 1. `AI_MEMORY_PERMISSIONS_MODE` env var (`enforce` /
6145 /// `advisory` / `off`, case-insensitive). Lets the integration
6146 /// suite — which sets `AI_MEMORY_NO_CONFIG=1` and therefore
6147 /// cannot use `[permissions]` from `config.toml` — flip the
6148 /// gate to Enforce per scenario.
6149 /// 2. `[permissions].mode` from `config.toml`.
6150 /// 3. v0.7.0 secure default ([`PermissionsMode::Enforce`]) when no
6151 /// explicit configuration is present. Round-2 F8 / Round-3
6152 /// re-verify: prior to this round the unconfigured fallback was
6153 /// [`PermissionsMode::default`] (= `advisory`), which left an
6154 /// upgrading deployment with `metadata.governance.write=owner`
6155 /// bypassable. We now resolve via
6156 /// [`crate::permissions::resolve_v07_default_mode`] so every
6157 /// process-wide entry point (CLI, MCP, HTTP serve) shares the
6158 /// same secure-by-default posture; operators who want advisory
6159 /// set `[permissions].mode = "advisory"` explicitly.
6160 #[must_use]
6161 pub fn effective_permissions_mode(&self) -> PermissionsMode {
6162 if let Ok(raw) = std::env::var("AI_MEMORY_PERMISSIONS_MODE") {
6163 match raw.to_ascii_lowercase().as_str() {
6164 "enforce" => return PermissionsMode::Enforce,
6165 "advisory" => return PermissionsMode::Advisory,
6166 "off" => return PermissionsMode::Off,
6167 other => {
6168 eprintln!(
6169 "ai-memory: AI_MEMORY_PERMISSIONS_MODE={other:?} is not a valid mode \
6170 (expected enforce / advisory / off); falling back to config.toml"
6171 );
6172 }
6173 }
6174 }
6175 // B4 (S5-M3) — both "block absent entirely" and "block present
6176 // but `mode =` omitted" must reach the secure default. The
6177 // `Option<PermissionsMode>` shape lets us collapse both to
6178 // `None` for the resolver so neither path silently inherits
6179 // the serde-derived `Advisory`. The migration WARN that
6180 // `resolve_v07_default_mode` emits when configured is `None`
6181 // is surfaced by the daemon's startup banner
6182 // (see `crate::cli::serve_banner::compose_banner`).
6183 let configured = self.permissions.as_ref().and_then(|p| p.mode);
6184 let (mode, _warn) = crate::permissions::resolve_v07_default_mode(configured);
6185 mode
6186 }
6187
6188 /// v0.7.0 K9 — resolve the effective declarative rule set
6189 /// consulted by [`crate::permissions::Permissions::evaluate`].
6190 ///
6191 /// Returns the rules from `[permissions]` when configured;
6192 /// otherwise an empty vec (no declarative rules — mode + hooks
6193 /// resolve every decision).
6194 #[must_use]
6195 pub fn effective_permission_rules(&self) -> Vec<crate::permissions::PermissionRule> {
6196 self.permissions
6197 .as_ref()
6198 .map(|p| p.rules.clone())
6199 .unwrap_or_default()
6200 }
6201
6202 /// Resolve the effective feature tier from config (CLI flag overrides).
6203 pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
6204 let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
6205 FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
6206 }
6207
6208 /// Resolve the effective database path (CLI flag overrides config).
6209 ///
6210 /// Expands a leading `~` / `~/` in the config-provided path to `$HOME`
6211 /// before returning (issue #507). Without this, `db = "~/.claude/ai-memory.db"`
6212 /// in `config.toml` would land on disk as the literal four-char dir
6213 /// `~/.claude/...` relative to cwd and the daemon would report
6214 /// `warn db unavailable` against the real DB that lives at the
6215 /// expanded path.
6216 pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
6217 // If CLI provided a non-default path, use it
6218 let default_db = PathBuf::from("ai-memory.db");
6219 if cli_db != default_db {
6220 return cli_db.to_path_buf();
6221 }
6222 // Otherwise check config — expanding leading `~` against $HOME.
6223 self.db
6224 .as_ref()
6225 .map_or_else(|| cli_db.to_path_buf(), |s| expand_tilde(s))
6226 }
6227
6228 /// Resolve Ollama URL for LLM generation (config or default).
6229 ///
6230 /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6231 /// New callers should use the sectioned `[llm]` resolver.
6232 #[allow(deprecated)]
6233 pub fn effective_ollama_url(&self) -> &str {
6234 self.ollama_url
6235 .as_deref()
6236 .unwrap_or("http://localhost:11434")
6237 }
6238
6239 /// Resolve TTL configuration from config file, falling back to compiled defaults.
6240 pub fn effective_ttl(&self) -> ResolvedTtl {
6241 ResolvedTtl::from_config(self.ttl.as_ref())
6242 }
6243
6244 /// Resolve recall-scoring configuration (time-decay half-life) from the
6245 /// config file, falling back to compiled defaults. v0.6.0.0.
6246 pub fn effective_scoring(&self) -> ResolvedScoring {
6247 ResolvedScoring::from_config(self.scoring.as_ref())
6248 }
6249
6250 /// Whether to archive memories before GC deletion (default: true).
6251 ///
6252 /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6253 #[allow(deprecated)]
6254 pub fn effective_archive_on_gc(&self) -> bool {
6255 self.archive_on_gc.unwrap_or(true)
6256 }
6257
6258 /// v0.7.0 H7 (round-2) — resolved per-request HTTP timeout.
6259 /// Falls back to [`DEFAULT_REQUEST_TIMEOUT_SECS`] when the
6260 /// `request_timeout_secs` config field is unset.
6261 #[must_use]
6262 pub fn effective_request_timeout_secs(&self) -> u64 {
6263 self.request_timeout_secs
6264 .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS)
6265 }
6266
6267 /// v0.7.0 H8 (round-2) — resolved per-LLM-call timeout. Falls
6268 /// back to [`DEFAULT_LLM_CALL_TIMEOUT_SECS`] when the
6269 /// `llm_call_timeout_secs` config field is unset.
6270 #[must_use]
6271 pub fn effective_llm_call_timeout_secs(&self) -> u64 {
6272 self.llm_call_timeout_secs
6273 .unwrap_or(DEFAULT_LLM_CALL_TIMEOUT_SECS)
6274 }
6275
6276 /// v0.6.4-001 — resolve the effective MCP tool profile.
6277 ///
6278 /// Resolution order:
6279 /// 1. `cli_or_env` (already merged by clap's `#[arg(env="AI_MEMORY_PROFILE")]`)
6280 /// 2. `[mcp].profile` config field
6281 /// 3. compiled default `"core"`
6282 ///
6283 /// # Errors
6284 ///
6285 /// Returns [`crate::profile::ProfileParseError`] if any layer's
6286 /// value is malformed (unknown family or mixed-case token).
6287 pub fn effective_profile(
6288 &self,
6289 cli_or_env: Option<&str>,
6290 ) -> Result<crate::profile::Profile, crate::profile::ProfileParseError> {
6291 let raw = cli_or_env
6292 .or_else(|| self.mcp.as_ref().and_then(|m| m.profile.as_deref()))
6293 .unwrap_or("core");
6294 crate::profile::Profile::parse(raw)
6295 }
6296
6297 /// Whether post-store autonomy hooks (`auto_tag` + `detect_contradiction`)
6298 /// fire on every successful `memory_store`. v0.6.0.0.
6299 /// Precedence: `AI_MEMORY_AUTONOMOUS_HOOKS=1` env var (truthy) >
6300 /// config file > default false. `AI_MEMORY_AUTONOMOUS_HOOKS=0` also
6301 /// honored for explicit-off.
6302 pub fn effective_autonomous_hooks(&self) -> bool {
6303 if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
6304 let v = v.trim().to_ascii_lowercase();
6305 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
6306 return true;
6307 }
6308 if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
6309 return false;
6310 }
6311 }
6312 self.autonomous_hooks.unwrap_or(false)
6313 }
6314
6315 /// Whether to anonymize the default `agent_id` fallback (Task 1.2 #198).
6316 /// Precedence: `AI_MEMORY_ANONYMIZE=1` env var (truthy) > config file > default false.
6317 pub fn effective_anonymize_default(&self) -> bool {
6318 if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
6319 let v = v.trim().to_ascii_lowercase();
6320 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
6321 return true;
6322 }
6323 if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
6324 return false;
6325 }
6326 }
6327 self.identity.as_ref().is_some_and(|i| i.anonymize_default)
6328 }
6329
6330 /// Resolve the [`LoggingConfig`] block, returning a default
6331 /// (disabled) instance when the config file omits it.
6332 pub fn effective_logging(&self) -> LoggingConfig {
6333 self.logging.clone().unwrap_or_default()
6334 }
6335
6336 /// Resolve the [`AuditConfig`] block, returning a default
6337 /// (disabled) instance when the config file omits it.
6338 pub fn effective_audit(&self) -> AuditConfig {
6339 self.audit.clone().unwrap_or_default()
6340 }
6341
6342 /// v0.7.0 I3 — resolve the [`TranscriptsConfig`] block, returning
6343 /// a default (no namespace overrides → compiled global defaults)
6344 /// instance when the config file omits it.
6345 #[must_use]
6346 pub fn effective_transcripts(&self) -> TranscriptsConfig {
6347 self.transcripts.clone().unwrap_or_default()
6348 }
6349
6350 /// Resolve the [`BootConfig`] block, returning a default
6351 /// (enabled, no redaction) instance when the config file omits
6352 /// it. v0.6.3.1 (PR-9h / issue #487 PR #497 req #73).
6353 pub fn effective_boot(&self) -> BootConfig {
6354 self.boot.clone().unwrap_or_default()
6355 }
6356
6357 /// Resolve URL for embedding model (falls back to `ollama_url`).
6358 ///
6359 /// DOC-6: legacy resolver — kept for v0.7.x backward compat.
6360 #[allow(deprecated)]
6361 pub fn effective_embed_url(&self) -> &str {
6362 self.embed_url
6363 .as_deref()
6364 .or(self.ollama_url.as_deref())
6365 .unwrap_or("http://localhost:11434")
6366 }
6367
6368 // ------------------------------------------------------------------
6369 // Canonical resolvers (#1146). Every LLM / embedder / reranker /
6370 // storage surface MUST consume the corresponding `Resolved*` shape
6371 // produced by these methods rather than reading raw config / env
6372 // / tier presets.
6373 //
6374 // Precedence (uniform across all four):
6375 // CLI flag > AI_MEMORY_* env > config.toml section
6376 // > legacy flat fields (Legacy source) > compiled default
6377 //
6378 // Resolvers are PURE (no network I/O). `resolve_llm` reads the
6379 // `api_key_file` content at call time if configured; perm checks
6380 // land in a follow-up commit and surface via `KeySource::Error`
6381 // without panicking.
6382 // ------------------------------------------------------------------
6383
6384 /// v0.7.x (#1146) — resolve the canonical LLM configuration.
6385 ///
6386 /// `cli_backend` / `cli_model` / `cli_base_url` carry CLI-flag
6387 /// overrides (pass `None` for `ai-memory mcp` / `ai-memory serve`
6388 /// which currently expose no CLI override; the CLI plumbing lands
6389 /// in a follow-up commit).
6390 ///
6391 /// DOC-6: this resolver intentionally reads the legacy flat
6392 /// fields as the lowest-precedence fallback layer (per the
6393 /// sectioned/v2 contract), so the `#[allow(deprecated)]`
6394 /// attribute is necessary here. External callers should pass
6395 /// CLI / env / `[llm]` section values and let this resolver
6396 /// reach for the legacy fields only when those are unset.
6397 #[must_use]
6398 #[allow(deprecated)]
6399 pub fn resolve_llm(
6400 &self,
6401 cli_backend: Option<&str>,
6402 cli_model: Option<&str>,
6403 cli_base_url: Option<&str>,
6404 ) -> ResolvedLlm {
6405 // ------- 1. backend selection ----------------------------------
6406 let env_backend = std::env::var("AI_MEMORY_LLM_BACKEND")
6407 .ok()
6408 .map(|s| s.trim().to_ascii_lowercase())
6409 .filter(|s| !s.is_empty());
6410 let cfg_backend = self
6411 .llm
6412 .as_ref()
6413 .and_then(|l| l.backend.as_ref())
6414 .map(|s| s.trim().to_ascii_lowercase())
6415 .filter(|s| !s.is_empty());
6416
6417 let (backend, source) = if let Some(b) = cli_backend.map(str::to_ascii_lowercase) {
6418 (b, ConfigSource::Cli)
6419 } else if let Some(b) = env_backend.clone() {
6420 (b, ConfigSource::Env)
6421 } else if let Some(b) = cfg_backend {
6422 (b, ConfigSource::Config)
6423 } else if self.llm_model.is_some() || self.ollama_url.is_some() {
6424 // Legacy flat fields imply Ollama.
6425 ("ollama".to_string(), ConfigSource::Legacy)
6426 } else {
6427 // Compiled default = tier preset (Ollama-native).
6428 ("ollama".to_string(), ConfigSource::CompiledDefault)
6429 };
6430
6431 // ------- 2. model selection ------------------------------------
6432 let model = cli_model
6433 .map(str::to_string)
6434 .filter(|s| !s.trim().is_empty())
6435 .or_else(|| {
6436 std::env::var("AI_MEMORY_LLM_MODEL")
6437 .ok()
6438 .filter(|s| !s.trim().is_empty())
6439 })
6440 .or_else(|| {
6441 self.llm
6442 .as_ref()
6443 .and_then(|l| l.model.clone())
6444 .filter(|s| !s.trim().is_empty())
6445 })
6446 .or_else(|| self.llm_model.clone().filter(|s| !s.trim().is_empty()))
6447 .unwrap_or_else(|| backend_default_model(&backend).to_string());
6448
6449 // ------- 3. base_url selection ---------------------------------
6450 let base_url = cli_base_url
6451 .map(str::to_string)
6452 .filter(|s| !s.trim().is_empty())
6453 .or_else(|| {
6454 std::env::var("AI_MEMORY_LLM_BASE_URL")
6455 .ok()
6456 .filter(|s| !s.trim().is_empty())
6457 })
6458 .or_else(|| {
6459 self.llm
6460 .as_ref()
6461 .and_then(|l| l.base_url.clone())
6462 .filter(|s| !s.trim().is_empty())
6463 })
6464 .or_else(|| {
6465 if backend == "ollama" {
6466 self.ollama_url.clone()
6467 } else {
6468 None
6469 }
6470 })
6471 .unwrap_or_else(|| backend_default_base_url(&backend).to_string());
6472
6473 // ------- 4. api_key selection ----------------------------------
6474 let (api_key, api_key_source) = resolve_api_key(&backend, self.llm.as_ref());
6475
6476 ResolvedLlm {
6477 backend,
6478 model,
6479 base_url,
6480 api_key,
6481 api_key_source,
6482 source,
6483 }
6484 }
6485
6486 /// v0.7.x (#1146) — resolve the `[llm.auto_tag]` fast-structured-
6487 /// output sibling. Fields fall back to [`Self::resolve_llm`] field-
6488 /// by-field; commonly only `model` is overridden (defaults to
6489 /// `gemma3:4b` per the L15 fast-structured-output policy).
6490 ///
6491 /// DOC-6: reads the legacy `auto_tag_model` field as the
6492 /// lowest-precedence fallback layer (`#[allow(deprecated)]`).
6493 #[must_use]
6494 #[allow(deprecated)]
6495 pub fn resolve_llm_auto_tag(&self) -> ResolvedLlm {
6496 let parent = self.resolve_llm(None, None, None);
6497 let sub = self.llm.as_ref().and_then(|l| l.auto_tag.as_ref());
6498
6499 let backend = sub
6500 .and_then(|s| s.backend.clone())
6501 .filter(|s| !s.trim().is_empty())
6502 .unwrap_or_else(|| parent.backend.clone());
6503
6504 let model = sub
6505 .and_then(|s| s.model.clone())
6506 .filter(|s| !s.trim().is_empty())
6507 .or_else(|| self.auto_tag_model.clone().filter(|s| !s.trim().is_empty()))
6508 .unwrap_or_else(|| {
6509 // L15 default: gemma3:4b for fast structured output,
6510 // regardless of parent backend.
6511 if backend == "ollama" {
6512 "gemma3:4b".to_string()
6513 } else {
6514 // For non-Ollama backends, use the parent model
6515 // (no sane way to pick a "fast" model across vendors).
6516 parent.model.clone()
6517 }
6518 });
6519
6520 let base_url = sub
6521 .and_then(|s| s.base_url.clone())
6522 .filter(|s| !s.trim().is_empty())
6523 .unwrap_or_else(|| {
6524 if backend == parent.backend {
6525 parent.base_url.clone()
6526 } else {
6527 backend_default_base_url(&backend).to_string()
6528 }
6529 });
6530
6531 // api_key: inherit from parent if backend matches, else fresh resolve.
6532 let (api_key, api_key_source) = if backend == parent.backend {
6533 (parent.api_key.clone(), parent.api_key_source.clone())
6534 } else {
6535 // Synthesise a transient LlmSection-like view from the sub-table
6536 // for fresh API-key resolution.
6537 let synthetic = sub.map(|s| LlmSection {
6538 backend: Some(backend.clone()),
6539 model: None,
6540 base_url: None,
6541 api_key_env: s.api_key_env.clone(),
6542 api_key_file: s.api_key_file.clone(),
6543 api_key: None,
6544 auto_tag: None,
6545 });
6546 resolve_api_key(&backend, synthetic.as_ref())
6547 };
6548
6549 ResolvedLlm {
6550 backend,
6551 model,
6552 base_url,
6553 api_key,
6554 api_key_source,
6555 source: parent.source,
6556 }
6557 }
6558
6559 /// v0.7.x (#1146) — resolve the canonical embedder configuration.
6560 ///
6561 /// #1598 — extended per-field precedence ladder:
6562 ///
6563 /// - `backend`: `AI_MEMORY_EMBED_BACKEND` env > `[embeddings].backend`
6564 /// > compiled default (`ollama`).
6565 /// - `url`: `AI_MEMORY_EMBED_BASE_URL` env > `[embeddings].base_url`
6566 /// > `[embeddings].url` > legacy `embed_url` > legacy `ollama_url`
6567 /// > the backend alias's default base URL (API backends) > the
6568 /// localhost Ollama default.
6569 /// - `model`: `AI_MEMORY_EMBED_MODEL` env > `[embeddings].model`
6570 /// > legacy `embedding_model` > compiled default
6571 /// (`nomic-embed-text-v1.5`); legacy aliases canonicalised.
6572 /// - `api_key`: [`resolve_embed_api_key`] ladder
6573 /// (`AI_MEMORY_EMBED_API_KEY` > per-vendor alias env >
6574 /// `[embeddings].api_key_env` > `[embeddings].api_key_file`).
6575 /// - `embedding_dim`: `[embeddings].dim` override >
6576 /// [`canonical_embedding_dim`] table > `None`.
6577 ///
6578 /// DOC-6: reads the legacy `embed_url`/`embedding_model`/
6579 /// `ollama_url` fields as the lowest-precedence fallback layer.
6580 #[must_use]
6581 #[allow(deprecated)]
6582 pub fn resolve_embeddings(&self) -> ResolvedEmbeddings {
6583 let cfg = self.embeddings.as_ref();
6584
6585 let env_backend = std::env::var(ENV_EMBED_BACKEND)
6586 .ok()
6587 .map(|s| s.trim().to_ascii_lowercase())
6588 .filter(|s| !s.is_empty());
6589 let backend = env_backend
6590 .clone()
6591 .or_else(|| {
6592 cfg.and_then(|e| e.backend.as_ref())
6593 .map(|s| s.trim().to_ascii_lowercase())
6594 .filter(|s| !s.is_empty())
6595 })
6596 .unwrap_or_else(|| crate::llm::BACKEND_OLLAMA.to_string());
6597
6598 let url = std::env::var(ENV_EMBED_BASE_URL)
6599 .ok()
6600 .filter(|s| !s.trim().is_empty())
6601 .or_else(|| {
6602 cfg.and_then(|e| e.base_url.clone())
6603 .filter(|s| !s.trim().is_empty())
6604 })
6605 .or_else(|| {
6606 cfg.and_then(|e| e.url.clone())
6607 .filter(|s| !s.trim().is_empty())
6608 })
6609 .or_else(|| self.embed_url.clone().filter(|s| !s.trim().is_empty()))
6610 .or_else(|| self.ollama_url.clone().filter(|s| !s.trim().is_empty()))
6611 .or_else(|| {
6612 // #1598 — API backends default to the vendor's base URL
6613 // (declared once in llm.rs); `openai-compatible` has no
6614 // sane default and falls through.
6615 if is_api_embed_backend(&backend) {
6616 crate::llm::default_base_url_for_alias(&backend).map(str::to_string)
6617 } else {
6618 None
6619 }
6620 })
6621 .unwrap_or_else(|| crate::llm::DEFAULT_OLLAMA_URL.to_string());
6622
6623 let model = std::env::var(ENV_EMBED_MODEL)
6624 .ok()
6625 .filter(|s| !s.trim().is_empty())
6626 .or_else(|| {
6627 cfg.and_then(|e| e.model.clone())
6628 .filter(|s| !s.trim().is_empty())
6629 })
6630 .or_else(|| {
6631 self.embedding_model
6632 .clone()
6633 .filter(|s| !s.trim().is_empty())
6634 })
6635 .map(canonicalise_embedding_model)
6636 .unwrap_or_else(|| DEFAULT_EMBED_MODEL.to_string());
6637
6638 let backfill_batch_env = std::env::var(ENV_EMBED_BACKFILL_BATCH)
6639 .ok()
6640 .and_then(|s| s.trim().parse::<u32>().ok());
6641 let backfill_batch_cfg = cfg.and_then(|e| e.backfill_batch);
6642 let backfill_batch_raw = backfill_batch_env.or(backfill_batch_cfg);
6643 let backfill_batch = match backfill_batch_raw {
6644 Some(n) if (1..=10000).contains(&n) => n,
6645 // #1649 — out-of-range values were silently swallowed while
6646 // the env-var table promised a warn-log (the sibling knob
6647 // AI_MEMORY_WEBHOOK_DISPATCH_CONCURRENCY already warns).
6648 Some(n) => {
6649 tracing::warn!(
6650 "{ENV_EMBED_BACKFILL_BATCH}={n} outside 1..=10000 — falling back to default {DEFAULT_EMBED_BACKFILL_BATCH}"
6651 );
6652 DEFAULT_EMBED_BACKFILL_BATCH
6653 }
6654 None => DEFAULT_EMBED_BACKFILL_BATCH,
6655 };
6656
6657 let source = if env_backend.is_some() {
6658 ConfigSource::Env
6659 } else if cfg.is_some() {
6660 ConfigSource::Config
6661 } else if self.embed_url.is_some()
6662 || self.embedding_model.is_some()
6663 || self.ollama_url.is_some()
6664 {
6665 ConfigSource::Legacy
6666 } else {
6667 ConfigSource::CompiledDefault
6668 };
6669
6670 // v0.7.x (#1169) — derive the dim from the resolved model id
6671 // via the canonical lookup table. #1598 — the explicit
6672 // `[embeddings].dim` override wins (escape hatch for models
6673 // not in [`KNOWN_EMBEDDING_DIMS`]); non-positive overrides are
6674 // ignored. None when neither layer knows the dim; callers
6675 // (capabilities surface) fall back to the tier preset's
6676 // compiled dim.
6677 let embedding_dim = cfg
6678 .and_then(|e| e.dim)
6679 .filter(|d| *d > 0)
6680 .or_else(|| canonical_embedding_dim(&model));
6681
6682 // #1598 (fleet follow-up) — the EXPLICIT override alone also
6683 // becomes the wire `dimensions` request for OpenAI-compatible
6684 // backends (Matryoshka truncation; see
6685 // [`ResolvedEmbeddings::requested_dim`]). Deliberately NOT
6686 // populated from the table lookup — a table dim describes the
6687 // model's native output and must not be re-requested.
6688 let requested_dim = cfg.and_then(|e| e.dim).filter(|d| *d > 0);
6689
6690 // #1598 — embedding API key (None for ollama / keyless
6691 // self-hosted endpoints).
6692 let (api_key, key_source) = resolve_embed_api_key(&backend, cfg);
6693
6694 ResolvedEmbeddings {
6695 backend,
6696 url,
6697 model,
6698 backfill_batch,
6699 embedding_dim,
6700 requested_dim,
6701 api_key,
6702 key_source,
6703 source,
6704 }
6705 }
6706
6707 /// v0.7.x (#1146) — resolve the canonical reranker configuration.
6708 /// Folds the legacy `cross_encoder: Option<bool>` flag into the
6709 /// `enabled` field; `model` defaults to `ms-marco-MiniLM-L-6-v2`.
6710 ///
6711 /// DOC-6: reads the legacy `cross_encoder` field as the
6712 /// lowest-precedence fallback layer.
6713 #[must_use]
6714 #[allow(deprecated)]
6715 pub fn resolve_reranker(&self) -> ResolvedReranker {
6716 let cfg = self.reranker.as_ref();
6717
6718 let enabled = cfg
6719 .and_then(|r| r.enabled)
6720 .or(self.cross_encoder)
6721 // Default reranker-on for the autonomous tier; off otherwise.
6722 // Boot wires the actual tier-default at the resolver call
6723 // site (it's already keyed off `tier_config.cross_encoder`).
6724 .unwrap_or(false);
6725
6726 let model = cfg
6727 .and_then(|r| r.model.clone())
6728 .filter(|s| !s.trim().is_empty())
6729 .unwrap_or_else(|| "ms-marco-MiniLM-L-6-v2".to_string());
6730
6731 // #1604 — rerank input sequence cap, uniform ladder:
6732 // env > [reranker] section > compiled default. Zero,
6733 // unparseable, or above-model-ceiling values fall through.
6734 let admissible = |n: &usize| *n > 0 && *n <= crate::reranker::CROSS_ENCODER_MAX_SEQ;
6735 let max_seq_tokens = std::env::var(ENV_RERANK_MAX_SEQ)
6736 .ok()
6737 .and_then(|s| s.trim().parse::<usize>().ok())
6738 .filter(admissible)
6739 .or_else(|| cfg.and_then(|r| r.max_seq_tokens).filter(admissible))
6740 .unwrap_or(crate::reranker::RERANK_MAX_SEQ_DEFAULT);
6741
6742 let source = if cfg.is_some() {
6743 ConfigSource::Config
6744 } else if self.cross_encoder.is_some() {
6745 ConfigSource::Legacy
6746 } else {
6747 ConfigSource::CompiledDefault
6748 };
6749
6750 ResolvedReranker {
6751 enabled,
6752 model,
6753 max_seq_tokens,
6754 source,
6755 }
6756 }
6757
6758 /// #1691/n14 — resolve the recall-reranker score floor. Uniform
6759 /// ladder: `AI_MEMORY_RERANK_SCORE_FLOOR` env > `[reranker].score_floor`
6760 /// config > compiled default ([`crate::reranker::RerankerScoreFloor::Off`]).
6761 /// Unparseable values at any layer fall through to the next.
6762 ///
6763 /// Kept as a dedicated resolver (rather than a field on
6764 /// [`ResolvedReranker`]) because [`crate::reranker::RerankerScoreFloor`]
6765 /// carries an `f64` and is therefore `PartialEq`-only, while
6766 /// `ResolvedReranker` / `ResolvedModels` derive `Eq`. Fed to
6767 /// [`crate::reranker::BatchedReranker::with_score_floor`] at the
6768 /// `serve` and `mcp` reranker build sites so the score-floor
6769 /// capability is finally operator-reachable (it was dead config
6770 /// before #1691/n14).
6771 #[must_use]
6772 pub fn resolve_reranker_score_floor(&self) -> crate::reranker::RerankerScoreFloor {
6773 std::env::var(ENV_RERANK_SCORE_FLOOR)
6774 .ok()
6775 .as_deref()
6776 .and_then(crate::reranker::RerankerScoreFloor::parse)
6777 .or_else(|| {
6778 self.reranker
6779 .as_ref()
6780 .and_then(|r| r.score_floor.as_deref())
6781 .and_then(crate::reranker::RerankerScoreFloor::parse)
6782 })
6783 .unwrap_or(crate::reranker::RerankerScoreFloor::Off)
6784 }
6785
6786 /// #1671 — whether `curator --reflect --all-namespaces` should
6787 /// reflect `namespace`. True ONLY when
6788 /// `[curator.reflection_namespaces."<ns>"]` exists with
6789 /// `enabled = true`. The conservative default is `false` (no config
6790 /// → no fan-out), matching the pre-#1671 inert-but-safe posture where
6791 /// `--all-namespaces` reflected nothing. A single `--namespace <ns>`
6792 /// invocation bypasses this gate at the call site.
6793 #[must_use]
6794 pub fn reflection_namespace_enabled(&self, namespace: &str) -> bool {
6795 self.curator
6796 .as_ref()
6797 .and_then(|c| c.reflection_namespaces.as_ref())
6798 .and_then(|m| m.get(namespace))
6799 .is_some_and(|cfg| cfg.enabled)
6800 }
6801
6802 /// n15 — resolve the confidence-decay half-life (days) for
6803 /// `namespace`: `[curator.confidence_decay_half_life_days."<ns>"]`
6804 /// when present, finite, and `> 0`, else the compiled
6805 /// [`crate::confidence::DEFAULT_HALF_LIFE_DAYS`].
6806 #[must_use]
6807 pub fn confidence_decay_half_life_for(&self, namespace: &str) -> f64 {
6808 self.curator
6809 .as_ref()
6810 .and_then(|c| c.confidence_decay_half_life_days.as_ref())
6811 .and_then(|m| m.get(namespace))
6812 .copied()
6813 .filter(|v| v.is_finite() && *v > 0.0)
6814 .unwrap_or(crate::confidence::DEFAULT_HALF_LIFE_DAYS)
6815 }
6816
6817 /// n15 — snapshot the per-namespace confidence-decay half-life
6818 /// overrides for boot-time seeding into the process-global resolver
6819 /// ([`crate::confidence::decay::set_namespace_half_life_overrides`]),
6820 /// keeping only finite, positive values. Empty when no `[curator]`
6821 /// overrides are configured.
6822 #[must_use]
6823 pub fn confidence_decay_half_life_overrides(&self) -> std::collections::HashMap<String, f64> {
6824 self.curator
6825 .as_ref()
6826 .and_then(|c| c.confidence_decay_half_life_days.as_ref())
6827 .map(|m| {
6828 m.iter()
6829 .filter(|(_, v)| v.is_finite() && **v > 0.0)
6830 .map(|(k, v)| (k.clone(), *v))
6831 .collect()
6832 })
6833 .unwrap_or_default()
6834 }
6835
6836 /// v0.7.x (issue #1168) — bundle the three model-resolver outputs
6837 /// into a single [`ResolvedModels`] triple for the capabilities
6838 /// surface (MCP `memory_capabilities`, HTTP `GET /api/v1/capabilities`).
6839 ///
6840 /// Routes through the canonical [`Self::resolve_llm`],
6841 /// [`Self::resolve_embeddings`], and [`Self::resolve_reranker`]
6842 /// resolvers so the capabilities `models.*` block reflects the
6843 /// same resolved configuration the live LLM client / embedder /
6844 /// reranker were built from, NEVER the compiled tier preset.
6845 ///
6846 /// Pairs with [`ResolvedModels::from_tier_preset`] (back-compat
6847 /// constructor for tests that scaffold a `TierConfig` without an
6848 /// `AppConfig`).
6849 #[must_use]
6850 pub fn resolve_models(&self) -> ResolvedModels {
6851 ResolvedModels {
6852 llm: self.resolve_llm(None, None, None),
6853 embeddings: self.resolve_embeddings(),
6854 reranker: self.resolve_reranker(),
6855 }
6856 }
6857
6858 /// v0.7.x (#1146) — resolve the canonical storage configuration.
6859 ///
6860 /// DOC-6: reads the legacy `default_namespace`/`archive_on_gc`/
6861 /// `archive_max_days`/`max_memory_mb` fields as the
6862 /// lowest-precedence fallback layer.
6863 #[must_use]
6864 #[allow(deprecated)]
6865 pub fn resolve_storage(&self) -> ResolvedStorage {
6866 let cfg = self.storage.as_ref();
6867
6868 // #1590 — track WHICH layer supplied `default_namespace` so
6869 // write-path consumers can distinguish an explicit operator
6870 // choice from the compiled fallback (only the former overrides
6871 // the historical per-surface defaults).
6872 let section_ns = cfg
6873 .and_then(|s| s.default_namespace.clone())
6874 .filter(|s| !s.trim().is_empty());
6875 let legacy_ns = self
6876 .default_namespace
6877 .clone()
6878 .filter(|s| !s.trim().is_empty());
6879 let default_namespace_source = if section_ns.is_some() {
6880 ConfigSource::Config
6881 } else if legacy_ns.is_some() {
6882 ConfigSource::Legacy
6883 } else {
6884 ConfigSource::CompiledDefault
6885 };
6886 let default_namespace = section_ns
6887 .or(legacy_ns)
6888 .unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string());
6889
6890 let archive_on_gc = cfg
6891 .and_then(|s| s.archive_on_gc)
6892 .or(self.archive_on_gc)
6893 .unwrap_or(true);
6894
6895 let archive_max_days = cfg
6896 .and_then(|s| s.archive_max_days)
6897 .or(self.archive_max_days);
6898
6899 let max_memory_mb = cfg.and_then(|s| s.max_memory_mb).or(self.max_memory_mb);
6900
6901 // #1579 B7 — sqlite mmap size, uniform ladder:
6902 // env > [storage] section > compiled default. `0` is a
6903 // deliberate operator choice (disable mmap) so the filter
6904 // admits it; negative / unparseable values fall through.
6905 let db_mmap_size_bytes = std::env::var(ENV_DB_MMAP_SIZE)
6906 .ok()
6907 .and_then(|s| s.trim().parse::<i64>().ok())
6908 .filter(|n| *n >= 0)
6909 .or_else(|| cfg.and_then(|s| s.db_mmap_size_bytes).filter(|n| *n >= 0))
6910 .unwrap_or(crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES);
6911
6912 let source = if cfg.is_some() {
6913 ConfigSource::Config
6914 } else if self.default_namespace.is_some()
6915 || self.archive_on_gc.is_some()
6916 || self.archive_max_days.is_some()
6917 || self.max_memory_mb.is_some()
6918 {
6919 ConfigSource::Legacy
6920 } else {
6921 ConfigSource::CompiledDefault
6922 };
6923
6924 ResolvedStorage {
6925 default_namespace,
6926 archive_on_gc,
6927 archive_max_days,
6928 max_memory_mb,
6929 db_mmap_size_bytes,
6930 default_namespace_source,
6931 source,
6932 }
6933 }
6934
6935 /// v0.7.x — resolve the operator-tunable capacity limits.
6936 ///
6937 /// Precedence ladder per field (highest wins):
6938 /// `AI_MEMORY_MAX_*` env > `[limits]` section > compiled default.
6939 /// Non-positive values (≤ 0) at any layer are treated as "unset" so
6940 /// a stray `0` never silently disables writes — the next layer down
6941 /// is consulted instead. The compiled defaults are the named
6942 /// `crate::quotas::DEFAULT_MAX_*` constants and
6943 /// [`crate::handlers::MAX_BULK_SIZE`]; no numeric literals live in
6944 /// this resolver.
6945 #[must_use]
6946 pub fn resolve_limits(&self) -> ResolvedLimits {
6947 let cfg = self.limits.as_ref();
6948
6949 fn env_pos_i64(name: &str) -> Option<i64> {
6950 std::env::var(name)
6951 .ok()
6952 .and_then(|s| s.trim().parse::<i64>().ok())
6953 .filter(|n| *n > 0)
6954 }
6955 fn env_pos_usize(name: &str) -> Option<usize> {
6956 std::env::var(name)
6957 .ok()
6958 .and_then(|s| s.trim().parse::<usize>().ok())
6959 .filter(|n| *n > 0)
6960 }
6961
6962 let mem_env = env_pos_i64(ENV_MAX_MEMORIES_PER_DAY);
6963 let mem_cfg = cfg.and_then(|l| l.max_memories_per_day).filter(|n| *n > 0);
6964 let max_memories_per_day = mem_env
6965 .or(mem_cfg)
6966 .unwrap_or(crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY);
6967
6968 let bytes_env = env_pos_i64(ENV_MAX_STORAGE_BYTES);
6969 let bytes_cfg = cfg.and_then(|l| l.max_storage_bytes).filter(|n| *n > 0);
6970 let max_storage_bytes = bytes_env
6971 .or(bytes_cfg)
6972 .unwrap_or(crate::quotas::DEFAULT_MAX_STORAGE_BYTES);
6973
6974 let links_env = env_pos_i64(ENV_MAX_LINKS_PER_DAY);
6975 let links_cfg = cfg.and_then(|l| l.max_links_per_day).filter(|n| *n > 0);
6976 let max_links_per_day = links_env
6977 .or(links_cfg)
6978 .unwrap_or(crate::quotas::DEFAULT_MAX_LINKS_PER_DAY);
6979
6980 let page_env = env_pos_usize(ENV_MAX_PAGE_SIZE);
6981 let page_cfg = cfg.and_then(|l| l.max_page_size).filter(|n| *n > 0);
6982 let max_page_size = page_env
6983 .or(page_cfg)
6984 .unwrap_or(crate::handlers::MAX_BULK_SIZE);
6985
6986 let source = if mem_env.is_some()
6987 || bytes_env.is_some()
6988 || links_env.is_some()
6989 || page_env.is_some()
6990 {
6991 ConfigSource::Env
6992 } else if mem_cfg.is_some()
6993 || bytes_cfg.is_some()
6994 || links_cfg.is_some()
6995 || page_cfg.is_some()
6996 {
6997 ConfigSource::Config
6998 } else {
6999 ConfigSource::CompiledDefault
7000 };
7001
7002 ResolvedLimits {
7003 max_memories_per_day,
7004 max_storage_bytes,
7005 max_links_per_day,
7006 max_page_size,
7007 source,
7008 }
7009 }
7010
7011 /// Resolve the Postgres connection-pool sizing knobs into a
7012 /// [`crate::store::PoolConfig`] for the daemon's `build_store_handle`.
7013 ///
7014 /// Follows the uniform precedence ladder, per field:
7015 ///
7016 /// ```text
7017 /// AI_MEMORY_PG_POOL_MAX / _MIN / _ACQUIRE_TIMEOUT_SECS env
7018 /// > top-level config.toml field
7019 /// > compiled default (PoolConfig::default())
7020 /// ```
7021 ///
7022 /// Mirrors [`Self::resolve_limits`]: any non-positive or unparseable
7023 /// value is filtered so it falls through to the next layer (a stray
7024 /// `0` `max_connections` can never collapse the pool to unusable).
7025 #[cfg(feature = "sal")]
7026 #[must_use]
7027 pub fn resolve_pg_pool(&self) -> crate::store::PoolConfig {
7028 fn env_pos_u32(name: &str) -> Option<u32> {
7029 std::env::var(name)
7030 .ok()
7031 .and_then(|s| s.trim().parse::<u32>().ok())
7032 .filter(|n| *n > 0)
7033 }
7034 fn env_pos_u64(name: &str) -> Option<u64> {
7035 std::env::var(name)
7036 .ok()
7037 .and_then(|s| s.trim().parse::<u64>().ok())
7038 .filter(|n| *n > 0)
7039 }
7040
7041 let defaults = crate::store::PoolConfig::default();
7042
7043 let max_connections = env_pos_u32(ENV_PG_POOL_MAX)
7044 .or_else(|| self.postgres_pool_max_connections.filter(|n| *n > 0))
7045 .unwrap_or(defaults.max_connections);
7046
7047 let min_connections = env_pos_u32(ENV_PG_POOL_MIN)
7048 .or_else(|| self.postgres_pool_min_connections.filter(|n| *n > 0))
7049 .unwrap_or(defaults.min_connections);
7050
7051 let acquire_timeout_secs = env_pos_u64(ENV_PG_ACQUIRE_TIMEOUT_SECS)
7052 .or_else(|| self.postgres_acquire_timeout_secs.filter(|n| *n > 0))
7053 .unwrap_or(defaults.acquire_timeout_secs);
7054
7055 crate::store::PoolConfig {
7056 max_connections,
7057 min_connections,
7058 acquire_timeout_secs,
7059 }
7060 }
7061
7062 /// Write a default config file if one doesn't exist yet.
7063 pub fn write_default_if_missing() {
7064 let Some(path) = Self::config_path() else {
7065 return;
7066 };
7067 if path.exists() {
7068 return;
7069 }
7070 if let Some(parent) = path.parent() {
7071 let _ = std::fs::create_dir_all(parent);
7072 }
7073 let default_toml = r#"# ai-memory configuration
7074# See: https://github.com/alphaonedev/ai-memory-mcp
7075
7076# Feature tier: keyword, semantic, smart, autonomous
7077# tier = "semantic"
7078
7079# Path to SQLite database
7080# db = "~/.claude/ai-memory.db"
7081
7082# Ollama base URL (for smart/autonomous tiers)
7083# ollama_url = "http://localhost:11434"
7084
7085# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
7086# embedding_model = "mini_lm_l6_v2"
7087
7088# LLM model tag for Ollama
7089# llm_model = "gemma4:e2b"
7090
7091# Dedicated model for auto_tag (short structured output).
7092# Defaults to gemma3:4b. Reasoning-heavy features still use llm_model.
7093# auto_tag_model = "gemma3:4b"
7094
7095# Enable neural cross-encoder reranking (autonomous tier)
7096# cross_encoder = true
7097
7098# Default namespace for new memories
7099# default_namespace = "global"
7100
7101# Memory budget in MB (for auto tier selection)
7102# max_memory_mb = 4096
7103
7104# Archive expired memories before GC deletion (default: true)
7105# archive_on_gc = true
7106
7107# Postgres connection-pool sizing (postgres store only; sqlite ignores).
7108# Precedence per field: AI_MEMORY_PG_POOL_MAX / _MIN /
7109# _ACQUIRE_TIMEOUT_SECS env > these fields > compiled default.
7110# Non-positive / unparseable values fall through to the default.
7111# postgres_pool_max_connections = 16 # hard ceiling on open connections
7112# postgres_pool_min_connections = 2 # always-open warm-connection floor
7113# postgres_acquire_timeout_secs = 30 # acquire() wait before erroring (secs)
7114
7115# Per-tier TTL overrides (uncomment to customize)
7116# [ttl]
7117# short_ttl_secs = 21600 # 6 hours (default)
7118# mid_ttl_secs = 604800 # 7 days (default)
7119# long_ttl_secs = 0 # 0 = never expires (default)
7120# short_extend_secs = 3600 # +1h on access (default)
7121# mid_extend_secs = 86400 # +1d on access (default)
7122
7123# v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
7124# Default-OFF. Uncomment + set enabled = true to capture every
7125# `tracing::*` call site to a rotating on-disk log file. See
7126# `docs/security/audit-trail.md` §SIEM ingestion guide for Splunk /
7127# Datadog / Elastic / Loki recipes.
7128# [logging]
7129# enabled = false
7130# path = "~/.local/state/ai-memory/logs/"
7131# max_size_mb = 100
7132# max_files = 30
7133# retention_days = 90
7134# structured = false # true = emit JSON lines for SIEM ingest
7135# level = "info" # tracing EnvFilter directive
7136# rotation = "daily" # minutely | hourly | daily | never
7137
7138# v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF.
7139# When enabled, every memory mutation emits one hash-chained JSON
7140# line per event suitable for SOC2 / HIPAA / GDPR / FedRAMP evidence.
7141# `ai-memory audit verify` walks the chain; `ai-memory logs tail`
7142# streams events.
7143# [audit]
7144# enabled = false
7145# path = "~/.local/state/ai-memory/audit/"
7146# schema_version = 1
7147# redact_content = true # v1 schema never emits content; reserved
7148# hash_chain = true
7149# attestation_cadence_minutes = 60
7150# append_only = true # best-effort chflags(2) / FS_IOC_SETFLAGS
7151
7152# Compliance presets. Set `applied = true` and the documented retention
7153# / cadence values override the defaults above. See
7154# `docs/security/audit-trail.md` §Compliance.
7155# [audit.compliance.soc2]
7156# applied = false
7157# retention_days = 730
7158# redact_content = true
7159# attestation_cadence_minutes = 60
7160#
7161# [audit.compliance.hipaa]
7162# applied = false
7163# retention_days = 2190
7164# redact_content = true
7165# encrypt_at_rest = true # pair with --features sqlcipher
7166#
7167# [audit.compliance.gdpr]
7168# applied = false
7169# retention_days = 1095
7170# redact_content = true
7171# pseudonymize_actors = true # reserved for v0.7+
7172#
7173# [audit.compliance.fedramp]
7174# applied = false
7175# retention_days = 1095
7176# redact_content = true
7177# attestation_cadence_minutes = 30
7178
7179# v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy controls.
7180# Default-ON (omit the section entirely for the historical pre-v0.6.3.1
7181# behavior). Two knobs:
7182#
7183# - `enabled = false` silences `ai-memory boot` entirely: empty stdout,
7184# empty stderr, exit 0. The SessionStart hook injects nothing. Use on
7185# privacy-sensitive hosts where memory titles must never enter CI
7186# logs. The env var `AI_MEMORY_BOOT_ENABLED=0` takes precedence over
7187# this config (same precedence pattern as PR-5's log-dir resolution).
7188#
7189# - `redact_titles = true` keeps the manifest header but replaces row
7190# `title` fields with `<redacted>` — useful for compliance contexts
7191# that need the audit-trail signal of "boot ran with N memories"
7192# without exposing memory subjects.
7193# [boot]
7194# enabled = true
7195# redact_titles = false
7196"#;
7197 let _ = std::fs::write(&path, default_toml);
7198 }
7199}
7200
7201// ---------------------------------------------------------------------------
7202// Tests
7203// ---------------------------------------------------------------------------
7204
7205#[cfg(test)]
7206#[allow(deprecated)] // DOC-6: tests intentionally exercise legacy AppConfig flat fields
7207mod tests {
7208 use super::*;
7209
7210 /// M9 — process-wide guard around every test that calls
7211 /// `std::env::set_var` / `std::env::remove_var`. Test binaries run
7212 /// in parallel by default (`cargo test --jobs N`); env mutation is
7213 /// process-global so two scenarios touching the same key race
7214 /// non-deterministically. Every test in this module that flips an
7215 /// env var MUST hold this mutex for the duration of its body.
7216 ///
7217 /// Poison-OK: a panicking scenario that drops the guard mid-mutation
7218 /// still hands the next caller a usable lock. Subsequent tests
7219 /// re-establish the env state they need on entry.
7220 fn env_var_lock() -> std::sync::MutexGuard<'static, ()> {
7221 use std::sync::{Mutex, OnceLock};
7222 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
7223 LOCK.get_or_init(|| Mutex::new(()))
7224 .lock()
7225 .unwrap_or_else(std::sync::PoisonError::into_inner)
7226 }
7227
7228 #[test]
7229 fn tier_roundtrip() {
7230 for tier in [
7231 FeatureTier::Keyword,
7232 FeatureTier::Semantic,
7233 FeatureTier::Smart,
7234 FeatureTier::Autonomous,
7235 ] {
7236 assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
7237 }
7238 }
7239
7240 #[test]
7241 fn budget_selection() {
7242 assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
7243 assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
7244 assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
7245 assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
7246 assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
7247 assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
7248 assert_eq!(
7249 FeatureTier::from_memory_budget(4096),
7250 FeatureTier::Autonomous
7251 );
7252 assert_eq!(
7253 FeatureTier::from_memory_budget(8192),
7254 FeatureTier::Autonomous
7255 );
7256 }
7257
7258 #[test]
7259 fn embedding_dimensions() {
7260 assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
7261 assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
7262 }
7263
7264 /// L2 fix — `AppConfig.embedding_model` is an `Option<String>` we
7265 /// must parse before handing it to `build_embedder`. This test
7266 /// pins the wire form (snake_case, matches serde rename_all),
7267 /// confirms case-insensitive + trim-tolerant parsing, and that
7268 /// garbage input produces an actionable Err rather than panicking.
7269 #[test]
7270 fn embedding_model_from_str() {
7271 use std::str::FromStr;
7272 assert_eq!(
7273 EmbeddingModel::from_str("mini_lm_l6_v2").unwrap(),
7274 EmbeddingModel::MiniLmL6V2
7275 );
7276 assert_eq!(
7277 EmbeddingModel::from_str("nomic_embed_v15").unwrap(),
7278 EmbeddingModel::NomicEmbedV15
7279 );
7280 // Case-insensitive: operators copy/paste from docs in any case.
7281 assert_eq!(
7282 EmbeddingModel::from_str("MINI_LM_L6_V2").unwrap(),
7283 EmbeddingModel::MiniLmL6V2
7284 );
7285 assert_eq!(
7286 EmbeddingModel::from_str("Nomic_Embed_V15").unwrap(),
7287 EmbeddingModel::NomicEmbedV15
7288 );
7289 // Trim whitespace — common TOML editing artifact.
7290 assert_eq!(
7291 EmbeddingModel::from_str(" mini_lm_l6_v2 ").unwrap(),
7292 EmbeddingModel::MiniLmL6V2
7293 );
7294 // Invalid input -> Err with a useful message naming the bad value.
7295 let err = EmbeddingModel::from_str("garbage").unwrap_err();
7296 assert!(err.contains("garbage"), "err message lost the input: {err}");
7297 assert!(
7298 err.contains("mini_lm_l6_v2") && err.contains("nomic_embed_v15"),
7299 "err message should list valid options: {err}"
7300 );
7301 }
7302
7303 /// #1521 — `from_canonical_id` must accept every form an operator
7304 /// might write in `[embeddings].model`: the snake wire form, the HF
7305 /// id (the `canonicalise_embedding_model` output), the unprefixed
7306 /// shortname, and the Ollama tag. This is what lets the sectioned
7307 /// config block drive the daemon embedder.
7308 #[test]
7309 fn embedding_model_from_canonical_id_accepts_all_forms() {
7310 // nomic family — snake, canonical HF id, Ollama tag, prefixed id.
7311 for id in [
7312 "nomic_embed_v15",
7313 "nomic-embed-text-v1.5",
7314 "nomic-embed-text",
7315 "nomic-ai/nomic-embed-text-v1.5",
7316 ] {
7317 assert_eq!(
7318 EmbeddingModel::from_canonical_id(id),
7319 Some(EmbeddingModel::NomicEmbedV15),
7320 "nomic alias {id:?} must resolve"
7321 );
7322 }
7323 // MiniLM family — snake, canonical HF id, shortname, Ollama tag.
7324 for id in [
7325 "mini_lm_l6_v2",
7326 "sentence-transformers/all-MiniLM-L6-v2",
7327 "all-MiniLM-L6-v2",
7328 "all-minilm",
7329 ] {
7330 assert_eq!(
7331 EmbeddingModel::from_canonical_id(id),
7332 Some(EmbeddingModel::MiniLmL6V2),
7333 "minilm alias {id:?} must resolve"
7334 );
7335 }
7336 // The canonicalised output of a legacy alias must round-trip.
7337 assert_eq!(
7338 EmbeddingModel::from_canonical_id(&canonicalise_embedding_model(
7339 "nomic_embed_v15".to_string()
7340 )),
7341 Some(EmbeddingModel::NomicEmbedV15)
7342 );
7343 // Case-insensitive + whitespace-trimmed.
7344 assert_eq!(
7345 EmbeddingModel::from_canonical_id(" NOMIC-EMBED-TEXT-V1.5 "),
7346 Some(EmbeddingModel::NomicEmbedV15)
7347 );
7348 // Models the 2-model daemon embedder cannot construct → None
7349 // (caller falls back to the tier preset), and empty → None.
7350 assert_eq!(EmbeddingModel::from_canonical_id("bge-large-en"), None);
7351 assert_eq!(EmbeddingModel::from_canonical_id("mxbai-embed-large"), None);
7352 assert_eq!(EmbeddingModel::from_canonical_id(""), None);
7353 assert_eq!(EmbeddingModel::from_canonical_id(" "), None);
7354 }
7355
7356 #[test]
7357 fn autonomous_has_cross_encoder() {
7358 let cfg = FeatureTier::Autonomous.config();
7359 assert!(cfg.cross_encoder);
7360 let caps = cfg.capabilities();
7361 assert!(caps.features.cross_encoder_reranking);
7362 // v0.7.0 recursive-learning (issue #655): Tasks 1-6 shipped
7363 // the primitive, so the planned-feature object is now
7364 // `planned=false, enabled=true, version="v0.7.0"`. The
7365 // pre-v0.6.3.1 honesty contract still uses the
7366 // `PlannedFeature` shape so the v1 bool projection
7367 // collapses cleanly back to `true`.
7368 assert!(!caps.features.memory_reflection.planned);
7369 assert!(caps.features.memory_reflection.enabled);
7370 assert_eq!(caps.features.memory_reflection.version, "v0.7.0");
7371 }
7372
7373 #[test]
7374 fn keyword_has_no_models() {
7375 let cfg = FeatureTier::Keyword.config();
7376 assert!(cfg.embedding_model.is_none());
7377 assert!(cfg.llm_model.is_none());
7378 assert!(!cfg.cross_encoder);
7379 assert_eq!(cfg.max_memory_mb, 0);
7380 }
7381
7382 #[test]
7383 fn capabilities_serialize() {
7384 let caps = FeatureTier::Smart.config().capabilities();
7385 let json = serde_json::to_string_pretty(&caps).unwrap();
7386 assert!(json.contains("\"tier\": \"smart\""));
7387 assert!(json.contains("nomic"));
7388 // The smart tier surfaces the provider-agnostic compiled default
7389 // model tag — asserted against the single source of truth, not a
7390 // copied literal, so no vendor/model string is pinned in the test.
7391 assert!(json.contains(default_tier_llm_model()));
7392 }
7393
7394 /// v0.6.3.1 (capabilities schema v2, P1 honesty patch).
7395 /// Round-trip the new struct through serde_json and assert the v2
7396 /// honesty contract: dropped fields absent, planned-feature blocks
7397 /// shaped correctly, runtime-state defaults conservative.
7398 #[test]
7399 fn capabilities_v2_zero_state_round_trip() {
7400 let _gate = lock_permissions_mode_for_test();
7401 // K3 default is `advisory` — clear any override that a
7402 // sibling test might have left behind so the
7403 // `permissions.mode` field reflects the documented zero-state.
7404 clear_permissions_mode_override_for_test();
7405 let caps = FeatureTier::Keyword.config().capabilities();
7406 let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7407
7408 assert_eq!(val["schema_version"], "2");
7409
7410 // permissions zero-state: mode="advisory" (was "ask" in v1),
7411 // active_rules=0. `rule_summary` dropped from v2.
7412 assert_eq!(val["permissions"]["mode"], "advisory");
7413 assert_eq!(val["permissions"]["active_rules"], 0);
7414 assert!(
7415 val["permissions"].get("rule_summary").is_none(),
7416 "v2 honesty patch drops `permissions.rule_summary` (no per-rule serializer)"
7417 );
7418 // v0.6.3.1 (P4, audit G1): inheritance posture surfaced.
7419 assert_eq!(val["permissions"]["inheritance"], "enforced");
7420
7421 // hooks zero-state: 0 registered. `by_event` dropped from v2.
7422 assert_eq!(val["hooks"]["registered_count"], 0);
7423 assert!(
7424 val["hooks"].get("by_event").is_none(),
7425 "v2 honesty patch drops `hooks.by_event` (no event registry)"
7426 );
7427
7428 // hooks zero-state: 0 registered, by_event dropped (P1 honesty)
7429 assert_eq!(val["hooks"]["registered_count"], 0);
7430 assert!(
7431 val["hooks"].get("by_event").is_none(),
7432 "v2 drops hooks.by_event (no event registry)"
7433 );
7434 // P5 (G9): webhook_events must always surface the canonical
7435 // lifecycle events so integrators can pin a subscribe filter
7436 // against them.
7437 //
7438 // v0.7.0 K4 — `approval_requested` joined the list.
7439 // v0.7 J4 / G14 — `memory_link_invalidated` also joined.
7440 // Total: seven canonical event types.
7441 let events = val["hooks"]["webhook_events"].as_array().unwrap();
7442 assert_eq!(events.len(), 7);
7443 for expected in [
7444 "memory_store",
7445 "memory_promote",
7446 "memory_delete",
7447 "memory_link_created",
7448 "memory_link_invalidated",
7449 "memory_consolidated",
7450 "approval_requested",
7451 ] {
7452 assert!(
7453 events.iter().any(|v| v.as_str() == Some(expected)),
7454 "webhook_events missing {expected}"
7455 );
7456 }
7457
7458 // compaction zero-state: planned, not enabled, optional fields omitted
7459 assert_eq!(val["compaction"]["planned"], true);
7460 assert_eq!(val["compaction"]["enabled"], false);
7461 assert_eq!(val["compaction"]["version"], "v0.8+");
7462 assert!(
7463 val["compaction"].get("interval_minutes").is_none(),
7464 "Option::None values must be skipped in serialization"
7465 );
7466 assert!(val["compaction"].get("last_run_at").is_none());
7467 assert!(val["compaction"].get("last_run_stats").is_none());
7468
7469 // approval zero-state: 0 pending. `subscribers` and
7470 // `default_timeout_seconds` dropped from v2.
7471 assert_eq!(val["approval"]["pending_requests"], 0);
7472 assert!(
7473 val["approval"].get("subscribers").is_none(),
7474 "v2 honesty patch drops `approval.subscribers` (no subscription API)"
7475 );
7476 assert!(
7477 val["approval"].get("default_timeout_seconds").is_none(),
7478 "v2 honesty patch drops `approval.default_timeout_seconds` (no sweeper)"
7479 );
7480
7481 // v0.7.0 #1324 — substrate ships at v0.7.0; capability flag
7482 // reads `planned: false, enabled: false` at zero-state (no rows
7483 // in `memory_transcripts`, no operator-wired R5 hook yet). The
7484 // live MCP / HTTP overlay flips `enabled: true` when the
7485 // transcripts row count is non-zero.
7486 assert_eq!(val["transcripts"]["planned"], false);
7487 assert_eq!(val["transcripts"]["enabled"], false);
7488 assert_eq!(val["transcripts"]["version"], env!("CARGO_PKG_VERSION"));
7489
7490 // memory_reflection: planned-feature object (was bool).
7491 // v0.7.0 recursive-learning (issue #655) Tasks 1-6 shipped the
7492 // primitive, so the flag is `planned=false, enabled=true,
7493 // version="v0.7.0"`.
7494 assert_eq!(val["features"]["memory_reflection"]["planned"], false);
7495 assert_eq!(val["features"]["memory_reflection"]["enabled"], true);
7496 assert_eq!(val["features"]["memory_reflection"]["version"], "v0.7.0");
7497
7498 // Runtime-state defaults are conservative — they get overlaid
7499 // at the handler boundary based on the live embedder + reranker
7500 // handles. With no overlays, the keyword-tier daemon reports
7501 // `disabled` / `off`.
7502 assert_eq!(val["features"]["recall_mode_active"], "disabled");
7503 assert_eq!(val["features"]["reranker_active"], "off");
7504
7505 // v0.7 J1 — kg_backend zero-state: no SAL adapter wired yet,
7506 // so the field is None and elided from the JSON wire. Older
7507 // clients that don't know the field round-trip cleanly.
7508 assert!(
7509 val.get("kg_backend").is_none(),
7510 "kg_backend must be skipped from JSON when None (pre-J2 zero-state)"
7511 );
7512
7513 // Round-trip back to a typed Capabilities and confirm field
7514 // identity (proves Deserialize works for all reshaped structs).
7515 let restored: Capabilities = serde_json::from_value(val).unwrap();
7516 assert_eq!(restored.schema_version, "2");
7517 assert_eq!(restored.permissions.mode, "advisory");
7518 assert!(restored.compaction.status.planned);
7519 // v0.7.0 #1324 — transcripts substrate ships at v0.7.0; the
7520 // capability flag was `planned: true` pre-#1324 (mis-advertised
7521 // the substrate as roadmap-only). Round-trip now pins
7522 // `planned: false`.
7523 assert!(!restored.transcripts.status.planned);
7524 assert_eq!(restored.features.recall_mode_active, RecallMode::Disabled);
7525 assert_eq!(restored.features.reranker_active, RerankerMode::Off);
7526 assert!(restored.kg_backend.is_none());
7527 }
7528
7529 /// v0.7 J1 — when a SAL adapter populates `kg_backend`, the wire
7530 /// shape must serialise the literal snake-case tag and round-trip
7531 /// cleanly. Operators read this through `ai-memory doctor` and
7532 /// `memory_capabilities` to verify which traversal path their
7533 /// daemon actually runs.
7534 #[test]
7535 fn capabilities_kg_backend_serialises_when_set() {
7536 let mut caps = FeatureTier::Keyword.config().capabilities();
7537 caps.kg_backend = Some("age".to_string());
7538 let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7539 assert_eq!(val["kg_backend"], "age");
7540
7541 caps.kg_backend = Some("cte".to_string());
7542 let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
7543 assert_eq!(val["kg_backend"], "cte");
7544
7545 // Round-trip the populated field for Deserialize coverage.
7546 let restored: Capabilities = serde_json::from_value(val).unwrap();
7547 assert_eq!(restored.kg_backend.as_deref(), Some("cte"));
7548 }
7549
7550 /// P1 honesty patch: legacy v1 projection preserves the old shape
7551 /// for clients that opt in via `Accept-Capabilities: v1`.
7552 #[test]
7553 fn capabilities_v1_projection_preserves_legacy_shape() {
7554 let caps = FeatureTier::Autonomous.config().capabilities();
7555 let v1 = caps.to_v1();
7556 let val: serde_json::Value = serde_json::to_value(&v1).unwrap();
7557
7558 // v1: no schema_version, no v2-only blocks
7559 assert!(
7560 val.get("schema_version").is_none(),
7561 "v1 has no schema_version"
7562 );
7563 assert!(
7564 val.get("permissions").is_none(),
7565 "v1 has no permissions block"
7566 );
7567 assert!(val.get("hooks").is_none());
7568 assert!(val.get("compaction").is_none());
7569 assert!(val.get("approval").is_none());
7570 assert!(val.get("transcripts").is_none());
7571
7572 // v1 keeps the four legacy top-level keys
7573 assert!(val["tier"].is_string());
7574 assert!(val["version"].is_string());
7575 assert!(val["features"].is_object());
7576 assert!(val["models"].is_object());
7577
7578 // v1 features.memory_reflection collapses to a bool. v0.7.0
7579 // recursive-learning (issue #655) Tasks 1-6 shipped the
7580 // primitive, so the v2 planned-feature object now has
7581 // `enabled = true` and the v1 bool projection is `true`.
7582 assert!(val["features"]["memory_reflection"].is_boolean());
7583 assert_eq!(val["features"]["memory_reflection"], true);
7584
7585 // v1 features carry no recall_mode_active / reranker_active
7586 assert!(val["features"].get("recall_mode_active").is_none());
7587 assert!(val["features"].get("reranker_active").is_none());
7588 }
7589
7590 #[test]
7591 fn config_default_is_empty() {
7592 let cfg = AppConfig::default();
7593 assert!(cfg.tier.is_none());
7594 assert!(cfg.db.is_none());
7595 assert!(cfg.ollama_url.is_none());
7596 }
7597
7598 #[test]
7599 fn config_parse_toml() {
7600 let toml_str = r#"
7601 tier = "smart"
7602 db = "/tmp/test.db"
7603 ollama_url = "http://localhost:11434"
7604 cross_encoder = true
7605 "#;
7606 let cfg: AppConfig = toml::from_str(toml_str).unwrap();
7607 assert_eq!(cfg.tier.as_deref(), Some("smart"));
7608 assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
7609 assert!(cfg.cross_encoder.unwrap());
7610 }
7611
7612 #[test]
7613 fn resolved_ttl_defaults_match_hardcoded() {
7614 let resolved = ResolvedTtl::default();
7615 assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR));
7616 assert_eq!(resolved.mid_ttl_secs, Some(crate::SECS_PER_WEEK));
7617 assert_eq!(resolved.long_ttl_secs, None);
7618 assert_eq!(resolved.short_extend_secs, crate::SECS_PER_HOUR);
7619 assert_eq!(resolved.mid_extend_secs, crate::SECS_PER_DAY);
7620 }
7621
7622 #[test]
7623 fn resolved_ttl_from_partial_config() {
7624 let cfg = TtlConfig {
7625 mid_ttl_secs: Some(90 * crate::SECS_PER_DAY), // ~3 months
7626 ..Default::default()
7627 };
7628 let resolved = ResolvedTtl::from_config(Some(&cfg));
7629 assert_eq!(resolved.short_ttl_secs, Some(6 * crate::SECS_PER_HOUR)); // unchanged
7630 assert_eq!(resolved.mid_ttl_secs, Some(90 * crate::SECS_PER_DAY)); // overridden
7631 assert_eq!(resolved.long_ttl_secs, None); // unchanged
7632 }
7633
7634 #[test]
7635 fn resolved_ttl_zero_means_no_expiry() {
7636 let cfg = TtlConfig {
7637 short_ttl_secs: Some(0),
7638 mid_ttl_secs: Some(0),
7639 ..Default::default()
7640 };
7641 let resolved = ResolvedTtl::from_config(Some(&cfg));
7642 assert_eq!(resolved.short_ttl_secs, None); // 0 → no expiry
7643 assert_eq!(resolved.mid_ttl_secs, None);
7644 }
7645
7646 #[test]
7647 fn resolved_ttl_clamps_overflow() {
7648 let cfg = TtlConfig {
7649 mid_ttl_secs: Some(i64::MAX),
7650 short_extend_secs: Some(-crate::SECS_PER_HOUR),
7651 ..Default::default()
7652 };
7653 let resolved = ResolvedTtl::from_config(Some(&cfg));
7654 // i64::MAX should be clamped to MAX_TTL_SECS (10 years)
7655 assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
7656 // negative extend should be clamped to 0
7657 assert_eq!(resolved.short_extend_secs, 0);
7658 }
7659
7660 #[test]
7661 fn ttl_config_parse_toml() {
7662 let toml_str = r#"
7663 tier = "semantic"
7664 archive_on_gc = false
7665 [ttl]
7666 mid_ttl_secs = 7776000
7667 short_extend_secs = 7200
7668 "#;
7669 let cfg: AppConfig = toml::from_str(toml_str).unwrap();
7670 assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
7671 assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
7672 assert!(!cfg.effective_archive_on_gc());
7673 }
7674
7675 #[test]
7676 fn resolved_ttl_tier_methods() {
7677 let resolved = ResolvedTtl::default();
7678 assert_eq!(
7679 resolved.ttl_for_tier(&Tier::Short),
7680 Some(6 * crate::SECS_PER_HOUR)
7681 );
7682 assert_eq!(
7683 resolved.ttl_for_tier(&Tier::Mid),
7684 Some(crate::SECS_PER_WEEK)
7685 );
7686 assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
7687 assert_eq!(
7688 resolved.extend_for_tier(&Tier::Short),
7689 Some(crate::SECS_PER_HOUR)
7690 );
7691 assert_eq!(
7692 resolved.extend_for_tier(&Tier::Mid),
7693 Some(crate::SECS_PER_DAY)
7694 );
7695 assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
7696 }
7697
7698 #[test]
7699 fn config_effective_tier() {
7700 let cfg = AppConfig {
7701 tier: Some("smart".to_string()),
7702 ..Default::default()
7703 };
7704 // CLI override wins
7705 assert_eq!(
7706 cfg.effective_tier(Some("autonomous")),
7707 FeatureTier::Autonomous
7708 );
7709 // Config value used when no CLI
7710 assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
7711 }
7712
7713 // --- v0.6.0.0 recall scoring (time-decay half-life) ---
7714
7715 #[test]
7716 fn scoring_defaults_match_spec() {
7717 let s = ResolvedScoring::default();
7718 assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
7719 assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
7720 assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
7721 assert!(!s.legacy_scoring);
7722 }
7723
7724 #[test]
7725 fn scoring_from_config_overrides() {
7726 let cfg = RecallScoringConfig {
7727 half_life_days_short: Some(3.5),
7728 half_life_days_mid: Some(14.0),
7729 half_life_days_long: Some(730.0),
7730 legacy_scoring: false,
7731 };
7732 let s = ResolvedScoring::from_config(Some(&cfg));
7733 assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
7734 assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
7735 assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
7736 }
7737
7738 #[test]
7739 fn scoring_clamps_out_of_range() {
7740 let cfg = RecallScoringConfig {
7741 half_life_days_short: Some(-10.0),
7742 half_life_days_mid: Some(0.0),
7743 half_life_days_long: Some(1_000_000.0),
7744 legacy_scoring: false,
7745 };
7746 let s = ResolvedScoring::from_config(Some(&cfg));
7747 assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
7748 assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
7749 assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
7750 }
7751
7752 #[test]
7753 fn scoring_decay_at_half_life_is_half() {
7754 let s = ResolvedScoring::default();
7755 // Short tier half-life is 7 days → at age=7d, decay=0.5
7756 let d = s.decay_multiplier(&Tier::Short, 7.0);
7757 assert!((d - 0.5).abs() < 1e-9);
7758 let d = s.decay_multiplier(&Tier::Mid, 30.0);
7759 assert!((d - 0.5).abs() < 1e-9);
7760 let d = s.decay_multiplier(&Tier::Long, 365.0);
7761 assert!((d - 0.5).abs() < 1e-9);
7762 }
7763
7764 #[test]
7765 fn scoring_decay_monotonic() {
7766 let s = ResolvedScoring::default();
7767 let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
7768 let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
7769 // Older memories decay more (lower multiplier).
7770 assert!(d_new > d_old);
7771 assert!(d_new < 1.0);
7772 assert!(d_old > 0.0);
7773 }
7774
7775 #[test]
7776 fn scoring_decay_zero_age_is_one() {
7777 let s = ResolvedScoring::default();
7778 assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
7779 // Negative ages (clock skew, future timestamps) are also treated as fresh.
7780 assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
7781 }
7782
7783 #[test]
7784 fn scoring_legacy_disables_decay() {
7785 let cfg = RecallScoringConfig {
7786 legacy_scoring: true,
7787 ..Default::default()
7788 };
7789 let s = ResolvedScoring::from_config(Some(&cfg));
7790 // No decay regardless of age.
7791 assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
7792 assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
7793 assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
7794 }
7795
7796 #[test]
7797 fn effective_scoring_on_empty_config() {
7798 let cfg = AppConfig::default();
7799 let s = cfg.effective_scoring();
7800 assert_eq!(s.half_life_days_short, 7.0);
7801 assert!(!s.legacy_scoring);
7802 }
7803
7804 #[test]
7805 fn scoring_roundtrip_through_toml() {
7806 let toml_src = r"
7807[scoring]
7808half_life_days_short = 5.0
7809half_life_days_mid = 25.0
7810legacy_scoring = false
7811";
7812 let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
7813 let s = cfg.effective_scoring();
7814 assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
7815 assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
7816 // Unset long defaults.
7817 assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
7818 }
7819
7820 // ---- Wave 3 (Closer T) tests for uncovered effective_* helpers
7821 // and write_default_if_missing. ----
7822
7823 #[test]
7824 fn effective_tier_cli_overrides_config() {
7825 let cfg = AppConfig {
7826 tier: Some("smart".to_string()),
7827 ..AppConfig::default()
7828 };
7829 // CLI flag wins over config.
7830 assert_eq!(
7831 cfg.effective_tier(Some("autonomous")),
7832 FeatureTier::Autonomous
7833 );
7834 // No CLI flag → config used.
7835 assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
7836 }
7837
7838 #[test]
7839 fn effective_tier_unknown_falls_back_to_semantic() {
7840 let cfg = AppConfig::default();
7841 assert_eq!(
7842 cfg.effective_tier(Some("invalid-tier")),
7843 FeatureTier::Semantic
7844 );
7845 // No CLI, no config → default semantic.
7846 assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
7847 }
7848
7849 // ---- v0.6.4-001 — `effective_profile` resolution tests.
7850 //
7851 // Resolution order: CLI/env > [mcp].profile config > "core" default.
7852 // Clap merges CLI and env into the same `Option<&str>` before this
7853 // function sees it, so the function only needs to test "explicit
7854 // override > config > default". Env-var precedence over CLI cannot
7855 // happen by design (clap precedence is CLI > env), so it is not
7856 // tested at this layer.
7857
7858 #[test]
7859 fn effective_profile_cli_or_env_overrides_config() {
7860 let cfg = AppConfig {
7861 mcp: Some(McpConfig {
7862 profile: Some("graph".to_string()),
7863 allowlist: None,
7864 ..McpConfig::default()
7865 }),
7866 ..AppConfig::default()
7867 };
7868 // CLI/env value beats the config value.
7869 assert_eq!(
7870 cfg.effective_profile(Some("admin")).unwrap(),
7871 crate::profile::Profile::admin()
7872 );
7873 // No CLI/env → config used.
7874 assert_eq!(
7875 cfg.effective_profile(None).unwrap(),
7876 crate::profile::Profile::graph()
7877 );
7878 }
7879
7880 #[test]
7881 fn effective_profile_falls_back_to_core_default() {
7882 let cfg = AppConfig::default();
7883 // No mcp config, no CLI → core (the v0.6.4 default flip).
7884 assert_eq!(
7885 cfg.effective_profile(None).unwrap(),
7886 crate::profile::Profile::core()
7887 );
7888 }
7889
7890 #[test]
7891 fn effective_profile_surfaces_parse_error_for_unknown_family() {
7892 let cfg = AppConfig::default();
7893 assert!(matches!(
7894 cfg.effective_profile(Some("xyz")),
7895 Err(crate::profile::ProfileParseError::UnknownFamily(_))
7896 ));
7897 }
7898
7899 #[test]
7900 fn effective_profile_surfaces_parse_error_for_mixed_case() {
7901 let cfg = AppConfig::default();
7902 assert!(matches!(
7903 cfg.effective_profile(Some("Core")),
7904 Err(crate::profile::ProfileParseError::CaseMismatch(_))
7905 ));
7906 }
7907
7908 // ---- v0.6.4-008 — `[mcp.allowlist]` resolution tests.
7909
7910 fn allowlist_table(rows: &[(&str, &[&str])]) -> McpConfig {
7911 let mut map = std::collections::HashMap::new();
7912 for (k, v) in rows {
7913 map.insert(
7914 (*k).to_string(),
7915 v.iter().map(|s| (*s).to_string()).collect(),
7916 );
7917 }
7918 McpConfig {
7919 profile: None,
7920 allowlist: Some(map),
7921 ..McpConfig::default()
7922 }
7923 }
7924
7925 #[test]
7926 fn allowlist_disabled_when_table_absent() {
7927 let cfg = McpConfig::default();
7928 assert_eq!(
7929 cfg.allowlist_decision(Some("alice"), "graph"),
7930 AllowlistDecision::Disabled
7931 );
7932 }
7933
7934 #[test]
7935 fn allowlist_disabled_when_table_empty() {
7936 let cfg = McpConfig {
7937 profile: None,
7938 allowlist: Some(std::collections::HashMap::new()),
7939 ..McpConfig::default()
7940 };
7941 assert_eq!(
7942 cfg.allowlist_decision(Some("alice"), "graph"),
7943 AllowlistDecision::Disabled
7944 );
7945 }
7946
7947 #[test]
7948 fn allowlist_exact_match_grants_or_denies_per_family_set() {
7949 let cfg = allowlist_table(&[("alice", &["core", "graph"]), ("*", &["core"])]);
7950 assert_eq!(
7951 cfg.allowlist_decision(Some("alice"), "graph"),
7952 AllowlistDecision::Allow
7953 );
7954 assert_eq!(
7955 cfg.allowlist_decision(Some("alice"), "power"),
7956 AllowlistDecision::Deny
7957 );
7958 }
7959
7960 #[test]
7961 fn allowlist_full_grants_every_family() {
7962 let cfg = allowlist_table(&[("bob", &["full"])]);
7963 assert_eq!(
7964 cfg.allowlist_decision(Some("bob"), "graph"),
7965 AllowlistDecision::Allow
7966 );
7967 assert_eq!(
7968 cfg.allowlist_decision(Some("bob"), "archive"),
7969 AllowlistDecision::Allow
7970 );
7971 }
7972
7973 #[test]
7974 fn allowlist_wildcard_default_for_unknown_agents() {
7975 let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
7976 assert_eq!(
7977 cfg.allowlist_decision(Some("eve"), "core"),
7978 AllowlistDecision::Allow
7979 );
7980 assert_eq!(
7981 cfg.allowlist_decision(Some("eve"), "graph"),
7982 AllowlistDecision::Deny
7983 );
7984 }
7985
7986 #[test]
7987 fn allowlist_default_deny_when_no_wildcard() {
7988 let cfg = allowlist_table(&[("alice", &["full"])]);
7989 assert_eq!(
7990 cfg.allowlist_decision(Some("eve"), "core"),
7991 AllowlistDecision::Deny
7992 );
7993 }
7994
7995 #[test]
7996 fn allowlist_longest_prefix_match_wins() {
7997 let cfg = allowlist_table(&[
7998 ("ai:", &["core"]),
7999 ("ai:claude-code", &["full"]),
8000 ("*", &["core"]),
8001 ]);
8002 // The longer prefix takes precedence over the shorter one.
8003 assert_eq!(
8004 cfg.allowlist_decision(Some("ai:claude-code@host"), "graph"),
8005 AllowlistDecision::Allow
8006 );
8007 // Shorter prefix still works for other ai:* agents.
8008 assert_eq!(
8009 cfg.allowlist_decision(Some("ai:codex@host"), "graph"),
8010 AllowlistDecision::Deny
8011 );
8012 }
8013
8014 #[test]
8015 fn allowlist_no_agent_id_uses_wildcard() {
8016 // Tier-1 / anonymous: no agent_id provided → only the wildcard
8017 // rule is consulted.
8018 let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
8019 assert_eq!(
8020 cfg.allowlist_decision(None, "core"),
8021 AllowlistDecision::Allow
8022 );
8023 assert_eq!(
8024 cfg.allowlist_decision(None, "graph"),
8025 AllowlistDecision::Deny
8026 );
8027 }
8028
8029 #[test]
8030 fn effective_db_cli_path_wins_when_non_default() {
8031 let cfg = AppConfig {
8032 db: Some("/from/config.db".to_string()),
8033 ..AppConfig::default()
8034 };
8035 let cli_path = Path::new("/from/cli.db");
8036 assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
8037 }
8038
8039 #[test]
8040 fn effective_db_falls_back_to_config_when_cli_default() {
8041 let cfg = AppConfig {
8042 db: Some("/from/config.db".to_string()),
8043 ..AppConfig::default()
8044 };
8045 // The CLI default is "ai-memory.db" — config wins for that case.
8046 assert_eq!(
8047 cfg.effective_db(Path::new("ai-memory.db")),
8048 PathBuf::from("/from/config.db")
8049 );
8050 }
8051
8052 #[test]
8053 fn effective_db_falls_back_to_cli_when_no_config() {
8054 let cfg = AppConfig::default();
8055 let cli_path = Path::new("ai-memory.db");
8056 assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
8057 }
8058
8059 #[test]
8060 fn effective_db_expands_tilde_against_home() {
8061 // #507: `db = "~/.claude/ai-memory.db"` must resolve to $HOME-based
8062 // path rather than the literal four-char prefix. Use env_var_lock
8063 // because HOME mutation is process-global.
8064 let _g = env_var_lock();
8065 let prev_home = std::env::var("HOME").ok();
8066 // SAFETY: serialized via env_var_lock; restored below.
8067 unsafe { std::env::set_var("HOME", "/expanded/home") };
8068 let cfg = AppConfig {
8069 db: Some("~/.claude/ai-memory.db".to_string()),
8070 ..AppConfig::default()
8071 };
8072 assert_eq!(
8073 cfg.effective_db(Path::new("ai-memory.db")),
8074 PathBuf::from("/expanded/home/.claude/ai-memory.db")
8075 );
8076 // Bare `~` resolves to $HOME itself.
8077 let cfg_bare = AppConfig {
8078 db: Some("~".to_string()),
8079 ..AppConfig::default()
8080 };
8081 assert_eq!(
8082 cfg_bare.effective_db(Path::new("ai-memory.db")),
8083 PathBuf::from("/expanded/home")
8084 );
8085 // Restore.
8086 match prev_home {
8087 Some(h) => unsafe { std::env::set_var("HOME", h) },
8088 None => unsafe { std::env::remove_var("HOME") },
8089 }
8090 }
8091
8092 #[test]
8093 fn effective_ollama_url_default_when_unset() {
8094 let cfg = AppConfig::default();
8095 assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
8096 }
8097
8098 #[test]
8099 fn effective_ollama_url_uses_configured_value() {
8100 let cfg = AppConfig {
8101 ollama_url: Some("http://my-host:9999".to_string()),
8102 ..AppConfig::default()
8103 };
8104 assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
8105 }
8106
8107 #[test]
8108 fn effective_embed_url_falls_back_to_ollama_url() {
8109 let cfg = AppConfig {
8110 ollama_url: Some("http://ollama:11434".to_string()),
8111 ..AppConfig::default()
8112 };
8113 // No embed_url → fall back to ollama_url.
8114 assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
8115 }
8116
8117 #[test]
8118 fn effective_embed_url_uses_dedicated_value_when_set() {
8119 let cfg = AppConfig {
8120 ollama_url: Some("http://ollama:11434".to_string()),
8121 embed_url: Some("http://embed:8080".to_string()),
8122 ..AppConfig::default()
8123 };
8124 // Dedicated embed_url wins.
8125 assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
8126 }
8127
8128 #[test]
8129 fn effective_embed_url_uses_default_when_neither_set() {
8130 let cfg = AppConfig::default();
8131 assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
8132 }
8133
8134 #[test]
8135 fn effective_archive_on_gc_default_is_true() {
8136 let cfg = AppConfig::default();
8137 assert!(cfg.effective_archive_on_gc());
8138 }
8139
8140 #[test]
8141 fn effective_archive_on_gc_respects_explicit_false() {
8142 let cfg = AppConfig {
8143 archive_on_gc: Some(false),
8144 ..AppConfig::default()
8145 };
8146 assert!(!cfg.effective_archive_on_gc());
8147 }
8148
8149 #[test]
8150 fn effective_autonomous_hooks_default_is_false() {
8151 // M9 — process-wide serialization via env_var_lock.
8152 let _g = env_var_lock();
8153 // SAFETY: env mutation serialised by `_g`.
8154 unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
8155 let cfg = AppConfig::default();
8156 assert!(!cfg.effective_autonomous_hooks());
8157 }
8158
8159 #[test]
8160 fn effective_autonomous_hooks_config_value_used_when_env_unset() {
8161 // M9 — process-wide serialization via env_var_lock.
8162 let _g = env_var_lock();
8163 // SAFETY: env mutation serialised by `_g`.
8164 unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
8165 let cfg = AppConfig {
8166 autonomous_hooks: Some(true),
8167 ..AppConfig::default()
8168 };
8169 assert!(cfg.effective_autonomous_hooks());
8170 }
8171
8172 #[test]
8173 fn effective_anonymize_default_falls_back_to_config() {
8174 // M9 — process-wide serialization via env_var_lock.
8175 let _g = env_var_lock();
8176 // SAFETY: env mutation serialised by `_g`.
8177 unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
8178 let cfg = AppConfig::default();
8179 assert!(!cfg.effective_anonymize_default());
8180 }
8181
8182 #[test]
8183 fn write_default_if_missing_creates_file_then_noops() {
8184 // M9 — process-wide serialization via env_var_lock.
8185 let _g = env_var_lock();
8186 // Use a temp dir as $HOME so we don't clobber a real config.
8187 let tmp = tempfile::tempdir().unwrap();
8188 // SAFETY: env mutation serialised by `_g`.
8189 unsafe { std::env::set_var("HOME", tmp.path()) };
8190 // First call writes the file.
8191 AppConfig::write_default_if_missing();
8192 let expected = AppConfig::config_path().unwrap();
8193 assert!(expected.exists(), "config not written at {expected:?}");
8194 let original = std::fs::read_to_string(&expected).unwrap();
8195 assert!(original.contains("ai-memory configuration"));
8196 // Second call must NOT overwrite (idempotent).
8197 std::fs::write(&expected, "# user-edited\n").unwrap();
8198 AppConfig::write_default_if_missing();
8199 let after = std::fs::read_to_string(&expected).unwrap();
8200 assert_eq!(after, "# user-edited\n");
8201 }
8202
8203 #[test]
8204 fn config_path_returns_some_when_home_set() {
8205 // M9 — process-wide serialization via env_var_lock.
8206 let _g = env_var_lock();
8207 // SAFETY: env mutation serialised by `_g`.
8208 unsafe { std::env::set_var("HOME", "/some/home") };
8209 let path = AppConfig::config_path().unwrap();
8210 assert!(path.starts_with("/some/home"));
8211 }
8212
8213 #[test]
8214 fn load_from_returns_default_for_missing_file() {
8215 // Non-existent path → default config.
8216 let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
8217 assert!(cfg.tier.is_none());
8218 assert!(cfg.db.is_none());
8219 }
8220
8221 #[test]
8222 fn load_from_returns_default_for_unparseable_toml() {
8223 // Garbage TOML → load_from prints a warning and returns default.
8224 let tmp = tempfile::NamedTempFile::new().unwrap();
8225 std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
8226 let cfg = AppConfig::load_from(tmp.path());
8227 assert!(cfg.tier.is_none());
8228 }
8229
8230 #[test]
8231 fn load_from_parses_valid_toml() {
8232 let tmp = tempfile::NamedTempFile::new().unwrap();
8233 std::fs::write(
8234 tmp.path(),
8235 r#"
8236 tier = "smart"
8237 db = "/disk.db"
8238 "#,
8239 )
8240 .unwrap();
8241 let cfg = AppConfig::load_from(tmp.path());
8242 assert_eq!(cfg.tier.as_deref(), Some("smart"));
8243 assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
8244 }
8245
8246 // -----------------------------------------------------------------
8247 // v0.7 I5 — auto_extract opt-in resolver
8248 // -----------------------------------------------------------------
8249
8250 #[test]
8251 fn auto_extract_default_off_when_no_namespaces_block() {
8252 let cfg = TranscriptsConfig::default();
8253 assert!(!cfg.auto_extract_for("agent/claude"));
8254 assert!(!cfg.auto_extract_for("anything"));
8255 }
8256
8257 #[test]
8258 fn auto_extract_exact_namespace_match_wins() {
8259 let mut nss = std::collections::HashMap::new();
8260 nss.insert(
8261 "agent/claude".into(),
8262 TranscriptNamespaceConfig {
8263 auto_extract: Some(true),
8264 ..Default::default()
8265 },
8266 );
8267 // Wildcard says "off" — exact match must still flip it on.
8268 nss.insert(
8269 "*".into(),
8270 TranscriptNamespaceConfig {
8271 auto_extract: Some(false),
8272 ..Default::default()
8273 },
8274 );
8275 let cfg = TranscriptsConfig {
8276 namespaces: Some(nss),
8277 ..Default::default()
8278 };
8279 assert!(cfg.auto_extract_for("agent/claude"));
8280 assert!(!cfg.auto_extract_for("agent/gpt"));
8281 }
8282
8283 #[test]
8284 fn auto_extract_prefix_match_then_wildcard_fallback() {
8285 let mut nss = std::collections::HashMap::new();
8286 nss.insert(
8287 "team/security/*".into(),
8288 TranscriptNamespaceConfig {
8289 auto_extract: Some(true),
8290 ..Default::default()
8291 },
8292 );
8293 nss.insert(
8294 "*".into(),
8295 TranscriptNamespaceConfig {
8296 auto_extract: Some(false),
8297 ..Default::default()
8298 },
8299 );
8300 let cfg = TranscriptsConfig {
8301 namespaces: Some(nss),
8302 ..Default::default()
8303 };
8304 assert!(cfg.auto_extract_for("team/security/audit"));
8305 assert!(!cfg.auto_extract_for("team/eng/main"));
8306 }
8307
8308 #[test]
8309 fn auto_extract_unset_field_inherits_default_off() {
8310 // A namespace block that sets only TTL — auto_extract is None
8311 // and so falls through to the next layer (wildcard, then off).
8312 let mut nss = std::collections::HashMap::new();
8313 nss.insert(
8314 "agent/claude".into(),
8315 TranscriptNamespaceConfig {
8316 default_ttl_secs: Some(crate::SECS_PER_HOUR),
8317 auto_extract: None,
8318 ..Default::default()
8319 },
8320 );
8321 let cfg = TranscriptsConfig {
8322 namespaces: Some(nss),
8323 ..Default::default()
8324 };
8325 assert!(!cfg.auto_extract_for("agent/claude"));
8326 }
8327
8328 // -----------------------------------------------------------------
8329 // L1 fix (v0.7.0): unknown top-level keys WARN diagnostic
8330 // -----------------------------------------------------------------
8331 //
8332 // The earlier Plan C bug planted `[memory]`, `[autonomous]`,
8333 // `[governance]`, `[federation]` tables in the operator's
8334 // config.toml — none of them are real `AppConfig` fields, so serde
8335 // silently dropped them and the operator's intent never reached the
8336 // daemon. The fix warns on every unknown top-level key while still
8337 // loading the config gracefully.
8338
8339 /// Top-level key not in `AppConfig` is reported via `tracing::warn!`
8340 /// AND the config still loads with recognised fields intact.
8341 #[test]
8342 fn load_from_warns_on_unknown_top_level_key_but_still_loads() {
8343 // Construct a config that mixes a real key (`tier`) with the
8344 // unknown `[memory]` table from the Plan C bug. The recognised
8345 // `tier = "autonomous"` at the top level must survive (i.e. the
8346 // unknown `[memory] tier = "ignored"` does NOT shadow it —
8347 // top-level wins because `[memory]` is a different namespace
8348 // entirely from `AppConfig.tier`).
8349 let toml_src = "tier = \"autonomous\"\n\n[memory]\ntier = \"ignored\"\n";
8350
8351 let tmp = tempfile::NamedTempFile::new().expect("create temp file");
8352 std::fs::write(tmp.path(), toml_src).expect("write temp config");
8353
8354 // We do NOT install a tracing subscriber here — `tracing-test`
8355 // is not a dev-dep, and the spec explicitly allows skipping the
8356 // "warn-was-emitted" assertion when capturing is awkward. The
8357 // important contract is:
8358 // (a) load_from returns a populated AppConfig (no panic),
8359 // (b) the recognised top-level `tier` survives,
8360 // (c) the unknown `[memory]` table did NOT block the load.
8361 // The warn itself is exercised at runtime — verify it fires by
8362 // running `RUST_LOG=warn AI_MEMORY_NO_CONFIG=0 ai-memory ...`
8363 // against a config with a stray section.
8364 let cfg = AppConfig::load_from(tmp.path());
8365
8366 assert_eq!(
8367 cfg.tier.as_deref(),
8368 Some("autonomous"),
8369 "top-level `tier` must survive even when an unknown `[memory]` table is present",
8370 );
8371 }
8372
8373 /// Every field in `AppConfig` is enumerated in the expected-key
8374 /// set, so renaming a struct field will not silently start
8375 /// emitting bogus warnings for the new name.
8376 ///
8377 /// Regression guard: if you add a new top-level field to
8378 /// `AppConfig`, you MUST also add it to the `EXPECTED_KEYS` const
8379 /// inside `AppConfig::warn_unknown_top_level_keys`. This test
8380 /// enforces parity by serialising a fully-populated `AppConfig` to
8381 /// TOML and asserting that every emitted top-level key is in the
8382 /// expected set.
8383 #[test]
8384 fn warn_unknown_top_level_keys_covers_every_appconfig_field() {
8385 // Build an AppConfig with every Option populated so serde emits
8386 // every field. We only need the keys, not the values, so
8387 // default placeholder sub-structs are fine.
8388 let cfg = AppConfig {
8389 tier: Some("keyword".into()),
8390 db: Some(String::new()),
8391 ollama_url: Some(String::new()),
8392 embed_url: Some(String::new()),
8393 embedding_model: Some(String::new()),
8394 llm_model: Some(String::new()),
8395 auto_tag_model: Some(String::new()),
8396 cross_encoder: Some(false),
8397 default_namespace: Some(String::new()),
8398 max_memory_mb: Some(0),
8399 ttl: Some(TtlConfig::default()),
8400 archive_on_gc: Some(false),
8401 api_key: Some(String::new()),
8402 archive_max_days: Some(0),
8403 identity: Some(IdentityConfig::default()),
8404 scoring: Some(RecallScoringConfig::default()),
8405 autonomous_hooks: Some(false),
8406 logging: Some(LoggingConfig::default()),
8407 audit: Some(AuditConfig::default()),
8408 boot: Some(BootConfig::default()),
8409 mcp: Some(McpConfig::default()),
8410 permissions: Some(PermissionsConfig::default()),
8411 transcripts: Some(TranscriptsConfig::default()),
8412 hooks: Some(HooksConfig::default()),
8413 subscriptions: Some(SubscriptionsConfig::default()),
8414 postgres_statement_timeout_secs: Some(30),
8415 postgres_pool_max_connections: Some(16),
8416 postgres_pool_min_connections: Some(2),
8417 postgres_acquire_timeout_secs: Some(30),
8418 request_timeout_secs: Some(60),
8419 llm_call_timeout_secs: Some(30),
8420 verify: Some(VerifyConfig::default()),
8421 mcp_federation_forward_url: Some(String::new()),
8422 agents: Some(AgentsConfig::default()),
8423 governance: Some(GovernanceConfig::default()),
8424 confidence: Some(ConfidenceConfig::default()),
8425 admin: Some(AdminConfig::default()),
8426 // v0.7.x (#1146) — enterprise configuration sections.
8427 schema_version: Some(2),
8428 llm: Some(LlmSection::default()),
8429 embeddings: Some(EmbeddingsSection::default()),
8430 reranker: Some(RerankerSection::default()),
8431 curator: Some(CuratorSection::default()),
8432 storage: Some(StorageSection::default()),
8433 limits: Some(LimitsSection::default()),
8434 };
8435
8436 let serialised = toml::to_string(&cfg).expect("serialise AppConfig to TOML");
8437 let value: toml::Value =
8438 toml::from_str(&serialised).expect("re-parse serialised AppConfig");
8439 let table = value.as_table().expect("serialised AppConfig is a table");
8440
8441 // Mirror the const in `warn_unknown_top_level_keys`. Keep in
8442 // sync — if this assertion fires, you forgot to update the
8443 // expected-keys list when adding a new AppConfig field.
8444 const EXPECTED_KEYS: &[&str] = &[
8445 "tier",
8446 "db",
8447 "ollama_url",
8448 "embed_url",
8449 "embedding_model",
8450 "llm_model",
8451 "auto_tag_model",
8452 "cross_encoder",
8453 "default_namespace",
8454 "max_memory_mb",
8455 "ttl",
8456 "archive_on_gc",
8457 "api_key",
8458 "archive_max_days",
8459 "identity",
8460 "scoring",
8461 "autonomous_hooks",
8462 "logging",
8463 "audit",
8464 "boot",
8465 "mcp",
8466 "permissions",
8467 "transcripts",
8468 "hooks",
8469 "subscriptions",
8470 "postgres_statement_timeout_secs",
8471 "postgres_pool_max_connections",
8472 "postgres_pool_min_connections",
8473 "postgres_acquire_timeout_secs",
8474 "request_timeout_secs",
8475 "llm_call_timeout_secs",
8476 "verify",
8477 "mcp_federation_forward_url",
8478 "agents",
8479 "governance",
8480 "confidence",
8481 "admin",
8482 // v0.7.x (#1146) — enterprise configuration sections.
8483 "schema_version",
8484 "llm",
8485 "embeddings",
8486 "reranker",
8487 "curator",
8488 "storage",
8489 "limits",
8490 ];
8491
8492 for key in table.keys() {
8493 assert!(
8494 EXPECTED_KEYS.contains(&key.as_str()),
8495 "AppConfig field `{key}` is not in EXPECTED_KEYS — \
8496 update `warn_unknown_top_level_keys` to keep parity",
8497 );
8498 }
8499 }
8500
8501 /// v0.7.0 L15 — assert that:
8502 /// 1. `AppConfig::default()` leaves `auto_tag_model` as `None` so a
8503 /// daemon with no operator override sees the absent state (which
8504 /// `maybe_auto_tag` interprets as "use the client's configured
8505 /// `llm_model`"); and
8506 /// 2. the documented default config.toml template spot-checks
8507 /// `gemma3:4b` as the recommended value — closes the L14
8508 /// NHI-D-autotag-empty finding where Gemma 4 thinking-mode
8509 /// latency hit the 30s autonomy timeout.
8510 #[test]
8511 fn auto_tag_model_default_falls_back_to_none_and_template_documents_default_gemma3_4b() {
8512 // (1) compile-time default leaves auto_tag_model = None.
8513 let cfg = AppConfig::default();
8514 assert!(
8515 cfg.auto_tag_model.is_none(),
8516 "fresh AppConfig must leave auto_tag_model = None so callers \
8517 fall back to llm_model"
8518 );
8519
8520 // (2) the default config.toml template the daemon writes to disk
8521 // must document the recommended gemma3:4b value and mention
8522 // auto_tag_model — operators rely on the inline template as the
8523 // authoritative knob reference.
8524 //
8525 // We can't reach the private `default_toml` constant directly,
8526 // so write it to a tempdir via `write_default_if_missing` and
8527 // read it back. Mirrors the pattern used by
8528 // `default_config_includes_*` tests above.
8529 //
8530 // M9 — HOME mutation is process-global; other tests in this
8531 // module also flip HOME. Serialise via env_var_lock so parallel
8532 // `cargo test --jobs N` runs cannot interleave reads of HOME
8533 // mid-mutation.
8534 let _g = env_var_lock();
8535 let tmp = tempfile::tempdir().expect("tempdir");
8536 // SAFETY: env mutation serialised by `_g`.
8537 unsafe { std::env::set_var("HOME", tmp.path()) };
8538 AppConfig::write_default_if_missing();
8539 let written = AppConfig::config_path().expect("config_path resolves");
8540 let contents = std::fs::read_to_string(&written).expect("default toml written");
8541 assert!(
8542 contents.contains("auto_tag_model"),
8543 "default config.toml must document the auto_tag_model knob; \
8544 got:\n{contents}"
8545 );
8546 assert!(
8547 contents.contains("gemma3:4b"),
8548 "default config.toml must mention gemma3:4b as the L15 \
8549 recommended default; got:\n{contents}"
8550 );
8551 }
8552
8553 // ---- C-5 (#699): close lib-tier gaps in config.rs (currently 90.76%).
8554 // Targets serde default functions, env-var override branches, and
8555 // display impls that no other test exercises. ----
8556
8557 #[test]
8558 fn tier_llm_model_is_agnostic_gate() {
8559 // The Gemma-only `LlmModel` enum was removed (#1490): no model name
8560 // survives as a config-surface identifier. The LLM-capable tiers
8561 // carry the provider-agnostic compiled default; keyword/semantic
8562 // carry `None` (LLM disabled). Pin the gate + the single-source-of-
8563 // truth default rather than any hardcoded vendor string.
8564 assert!(FeatureTier::Keyword.config().llm_model.is_none());
8565 assert!(FeatureTier::Semantic.config().llm_model.is_none());
8566 assert_eq!(
8567 FeatureTier::Smart.config().llm_model.as_deref(),
8568 Some(default_tier_llm_model())
8569 );
8570 assert_eq!(
8571 FeatureTier::Autonomous.config().llm_model.as_deref(),
8572 Some(default_tier_llm_model())
8573 );
8574 // The default routes through the agnostic resolver table, never a
8575 // model-named identifier.
8576 assert_eq!(
8577 default_tier_llm_model(),
8578 backend_default_model(crate::llm::BACKEND_OLLAMA)
8579 );
8580 }
8581
8582 #[test]
8583 fn feature_tier_display_matches_as_str() {
8584 // Lines 183-185: `FeatureTier::Display::fmt` writes `as_str`.
8585 assert_eq!(format!("{}", FeatureTier::Keyword), "keyword");
8586 assert_eq!(format!("{}", FeatureTier::Semantic), "semantic");
8587 assert_eq!(format!("{}", FeatureTier::Smart), "smart");
8588 assert_eq!(format!("{}", FeatureTier::Autonomous), "autonomous");
8589 }
8590
8591 #[test]
8592 fn default_recall_mode_is_disabled() {
8593 // Lines 630-632: serde default helper.
8594 assert_eq!(default_recall_mode(), RecallMode::Disabled);
8595 }
8596
8597 #[test]
8598 fn default_reranker_mode_is_off() {
8599 // Lines 634-636: serde default helper.
8600 assert_eq!(default_reranker_mode(), RerankerMode::Off);
8601 }
8602
8603 #[test]
8604 fn default_hook_events_count_matches_constant() {
8605 // Lines 731-733: serde default helper.
8606 assert_eq!(default_hook_events_count(), HOOK_EVENTS_COUNT);
8607 }
8608
8609 #[test]
8610 fn default_reflection_boost_returns_default_report() {
8611 // Lines 621-623: serde default helper. Calls the `Default::default`
8612 // impl on `ReflectionBoostReport`.
8613 let r = default_reflection_boost();
8614 let d = ReflectionBoostReport::default();
8615 // Lazy compare via Debug — the struct has no PartialEq.
8616 assert_eq!(format!("{r:?}"), format!("{d:?}"));
8617 }
8618
8619 #[test]
8620 fn permissions_mode_default_is_advisory() {
8621 // Lines 2403-2405: `impl Default for PermissionsMode`.
8622 let m: PermissionsMode = Default::default();
8623 assert_eq!(m, PermissionsMode::Advisory);
8624 }
8625
8626 #[test]
8627 fn active_permissions_mode_uses_named_fallback_when_unset_then_honors_setter() {
8628 // v0.7.0 H2 de-silencing: when boot has NOT installed a mode,
8629 // the gate reader returns the explicit
8630 // UNINITIALIZED_PERMISSIONS_MODE_FALLBACK constant (and emits a
8631 // one-shot WARN). Once a mode is installed, the reader honors it.
8632 let _serialise = lock_permissions_mode_for_test();
8633 clear_permissions_mode_override_for_test();
8634 assert_eq!(
8635 active_permissions_mode(),
8636 UNINITIALIZED_PERMISSIONS_MODE_FALLBACK,
8637 "unset gate must return the named pre-init fallback"
8638 );
8639 set_active_permissions_mode(PermissionsMode::Enforce);
8640 assert_eq!(
8641 active_permissions_mode(),
8642 PermissionsMode::Enforce,
8643 "installed mode must win over the fallback"
8644 );
8645 // Restore the unset state for subsequent tests.
8646 clear_permissions_mode_override_for_test();
8647 }
8648
8649 #[test]
8650 fn set_allow_loopback_webhooks_round_trips() {
8651 // Lines 2357-2359: pub setter — just observe it does not panic
8652 // and that effective_allow_loopback_webhooks can read the value.
8653 // (The atomic is process-global; restore the prior value at end.)
8654 let prior = ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst);
8655 set_allow_loopback_webhooks(true);
8656 assert!(ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
8657 set_allow_loopback_webhooks(false);
8658 assert!(!ALLOW_LOOPBACK_WEBHOOKS.load(std::sync::atomic::Ordering::SeqCst));
8659 // Restore.
8660 ALLOW_LOOPBACK_WEBHOOKS.store(prior, std::sync::atomic::Ordering::SeqCst);
8661 }
8662
8663 #[test]
8664 fn reset_permissions_decision_counts_zeros_all_atomics() {
8665 // Lines 2619-2623: test-only reset helper. Increment then reset.
8666 // Post-#1174 PR7: counters live behind the `DECISION_COUNTERS`
8667 // struct; we exercise them via the public surface to keep the
8668 // test resilient to internal reshape.
8669 let _serialise = lock_permissions_mode_for_test();
8670 reset_permissions_decision_counts_for_test();
8671 record_permissions_decision(PermissionsMode::Enforce);
8672 record_permissions_decision(PermissionsMode::Enforce);
8673 record_permissions_decision(PermissionsMode::Enforce);
8674 record_permissions_decision(PermissionsMode::Enforce);
8675 record_permissions_decision(PermissionsMode::Enforce);
8676 record_permissions_decision(PermissionsMode::Advisory);
8677 record_permissions_decision(PermissionsMode::Advisory);
8678 record_permissions_decision(PermissionsMode::Advisory);
8679 record_permissions_decision(PermissionsMode::Off);
8680 let pre = permissions_decision_counts();
8681 assert_eq!(pre.enforce, 5);
8682 assert_eq!(pre.advisory, 3);
8683 assert_eq!(pre.off, 1);
8684 reset_permissions_decision_counts_for_test();
8685 let post = permissions_decision_counts();
8686 assert_eq!(post.enforce, 0);
8687 assert_eq!(post.advisory, 0);
8688 assert_eq!(post.off, 0);
8689 }
8690
8691 #[test]
8692 fn effective_allow_loopback_webhooks_env_var_true_returns_true() {
8693 // Lines 2281-2297: env-var override branch (truthy).
8694 let _g = env_var_lock();
8695 let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8696 unsafe {
8697 std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "yes");
8698 }
8699 let cfg = AppConfig::default();
8700 assert!(cfg.effective_allow_loopback_webhooks());
8701 unsafe {
8702 match prior {
8703 Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8704 None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8705 }
8706 }
8707 }
8708
8709 #[test]
8710 fn effective_allow_loopback_webhooks_env_var_false_returns_false() {
8711 // Lines 2281-2297: env-var override (falsy).
8712 let _g = env_var_lock();
8713 let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8714 unsafe {
8715 std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "no");
8716 }
8717 let cfg = AppConfig::default();
8718 assert!(!cfg.effective_allow_loopback_webhooks());
8719 unsafe {
8720 match prior {
8721 Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8722 None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8723 }
8724 }
8725 }
8726
8727 #[test]
8728 fn effective_allow_loopback_webhooks_env_var_invalid_falls_back_to_config() {
8729 // Lines 2286-2292: invalid env value falls back to config.toml.
8730 let _g = env_var_lock();
8731 let prior = std::env::var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS").ok();
8732 unsafe {
8733 std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", "kinda");
8734 }
8735 let cfg = AppConfig::default();
8736 // With no [subscriptions] table the default is false.
8737 assert!(!cfg.effective_allow_loopback_webhooks());
8738 unsafe {
8739 match prior {
8740 Some(v) => std::env::set_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS", v),
8741 None => std::env::remove_var("AI_MEMORY_ALLOW_LOOPBACK_WEBHOOKS"),
8742 }
8743 }
8744 }
8745
8746 #[test]
8747 fn effective_permissions_mode_env_var_enforce_wins() {
8748 // Lines 3144-3169: env override path → Enforce.
8749 let _g = env_var_lock();
8750 let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8751 unsafe {
8752 std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "enforce");
8753 }
8754 let cfg = AppConfig::default();
8755 assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Enforce);
8756 unsafe {
8757 match prior {
8758 Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8759 None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8760 }
8761 }
8762 }
8763
8764 #[test]
8765 fn effective_permissions_mode_env_var_advisory_wins() {
8766 // Lines 3148: env override path → Advisory.
8767 let _g = env_var_lock();
8768 let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8769 unsafe {
8770 std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "ADVISORY");
8771 }
8772 let cfg = AppConfig::default();
8773 assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Advisory);
8774 unsafe {
8775 match prior {
8776 Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8777 None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8778 }
8779 }
8780 }
8781
8782 #[test]
8783 fn effective_permissions_mode_env_var_off_wins() {
8784 // Lines 3149: env override path → Off.
8785 let _g = env_var_lock();
8786 let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8787 unsafe {
8788 std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "off");
8789 }
8790 let cfg = AppConfig::default();
8791 assert_eq!(cfg.effective_permissions_mode(), PermissionsMode::Off);
8792 unsafe {
8793 match prior {
8794 Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8795 None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8796 }
8797 }
8798 }
8799
8800 #[test]
8801 fn effective_permissions_mode_env_var_invalid_falls_back_to_config() {
8802 // Lines 3150-3156: invalid env → falls through to resolve_v07_default_mode.
8803 let _g = env_var_lock();
8804 let prior = std::env::var("AI_MEMORY_PERMISSIONS_MODE").ok();
8805 unsafe {
8806 std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", "weird");
8807 }
8808 let cfg = AppConfig::default();
8809 // The resolver returns a value (we don't pin which — just that it returns).
8810 let _ = cfg.effective_permissions_mode();
8811 unsafe {
8812 match prior {
8813 Some(v) => std::env::set_var("AI_MEMORY_PERMISSIONS_MODE", v),
8814 None => std::env::remove_var("AI_MEMORY_PERMISSIONS_MODE"),
8815 }
8816 }
8817 }
8818
8819 #[test]
8820 fn effective_permission_rules_returns_empty_when_unset() {
8821 // Lines 3178-3183: empty-rules path.
8822 let cfg = AppConfig::default();
8823 let rules = cfg.effective_permission_rules();
8824 assert!(rules.is_empty());
8825 }
8826
8827 #[test]
8828 fn app_config_load_with_no_config_env_returns_default() {
8829 // Lines 3015-3022: `AppConfig::load` with AI_MEMORY_NO_CONFIG=1.
8830 let _g = env_var_lock();
8831 let prior = std::env::var("AI_MEMORY_NO_CONFIG").ok();
8832 unsafe {
8833 std::env::set_var("AI_MEMORY_NO_CONFIG", "1");
8834 }
8835 let cfg = AppConfig::load();
8836 // Default config has no tier/db set.
8837 assert!(
8838 cfg.tier.is_none()
8839 || cfg.tier == Some("semantic".to_string())
8840 || cfg.tier == Some("keyword".to_string())
8841 );
8842 unsafe {
8843 match prior {
8844 Some(v) => std::env::set_var("AI_MEMORY_NO_CONFIG", v),
8845 None => std::env::remove_var("AI_MEMORY_NO_CONFIG"),
8846 }
8847 }
8848 }
8849
8850 // ---- C-5 (#699) round 2: round out the easy Default impls + serde
8851 // default helpers that bumped lines 805/852/955/1019/1057/1125/1634+ ----
8852
8853 #[test]
8854 fn capability_compaction_default_is_planned() {
8855 // Lines 804-808.
8856 let d: CapabilityCompaction = Default::default();
8857 let planned = CapabilityCompaction::planned();
8858 // Compare via Debug since the struct has no PartialEq.
8859 assert_eq!(format!("{d:?}"), format!("{planned:?}"));
8860 }
8861
8862 #[test]
8863 fn capability_transcripts_default_is_planned() {
8864 // Lines 851-855.
8865 let d: CapabilityTranscripts = Default::default();
8866 let planned = CapabilityTranscripts::planned();
8867 assert_eq!(format!("{d:?}"), format!("{planned:?}"));
8868 }
8869
8870 #[test]
8871 fn default_capability_reflection_helper_returns_current() {
8872 // Lines 955-957.
8873 let helper = default_capability_reflection();
8874 let current = CapabilityReflection::current();
8875 assert_eq!(format!("{helper:?}"), format!("{current:?}"));
8876 }
8877
8878 #[test]
8879 fn issue_1672_curator_mode_honest_per_sal_feature() {
8880 // curator --reflect is sal-gated; curator_mode must report the honest
8881 // value for the build feature set, not a blanket "implemented".
8882 let cm = CapabilityReflection::current().curator_mode;
8883 if cfg!(feature = "sal") {
8884 assert_eq!(cm, IMPLEMENTED);
8885 } else {
8886 assert_eq!(cm, CURATOR_MODE_REQUIRES_SAL);
8887 }
8888 }
8889
8890 #[test]
8891 fn default_capability_skills_helper_returns_current() {
8892 // Lines 1019-1021.
8893 let helper = default_capability_skills();
8894 let current = CapabilitySkills::current();
8895 assert_eq!(helper, current);
8896 }
8897
8898 #[test]
8899 fn default_capability_forensic_helper_returns_current() {
8900 // Lines 1057-1059.
8901 let helper = default_capability_forensic();
8902 let current = CapabilityForensic::current();
8903 assert_eq!(helper, current);
8904 }
8905
8906 #[test]
8907 fn default_capability_governance_helper_returns_current() {
8908 // Lines 1125-1127.
8909 let helper = default_capability_governance();
8910 let current = CapabilityGovernance::current();
8911 assert_eq!(helper, current);
8912 }
8913
8914 #[test]
8915 fn default_capability_atomisation_helper_returns_current() {
8916 // v0.7.0 WT-1-G — mirrors the governance/forensic/skills/reflection
8917 // helper round-trip: the `#[serde(default = …)]` resolver must
8918 // collapse to the same compile-anchored snapshot
8919 // [`CapabilityAtomisation::current`] returns.
8920 let helper = default_capability_atomisation();
8921 let current = CapabilityAtomisation::current();
8922 assert_eq!(helper, current);
8923 }
8924
8925 #[test]
8926 fn resolved_transcript_lifecycle_default_uses_compiled_defaults() {
8927 // Lines 1633-1639.
8928 let r: ResolvedTranscriptLifecycle = Default::default();
8929 assert_eq!(r.default_ttl_secs, DEFAULT_TRANSCRIPT_TTL_SECS);
8930 assert_eq!(r.archive_grace_secs, DEFAULT_TRANSCRIPT_ARCHIVE_GRACE_SECS);
8931 }
8932
8933 #[test]
8934 fn default_memory_kinds_lists_observation_and_reflection() {
8935 // Lines 626-628: serde default helper covers L1-1 typed kinds.
8936 let kinds = default_memory_kinds();
8937 assert_eq!(
8938 kinds,
8939 vec!["observation".to_string(), "reflection".to_string()]
8940 );
8941 }
8942
8943 /// v0.7.0 Gap 4 (#887) — pin the capabilities-surface thresholds
8944 /// to the `ConfidenceTier` model constants so a future
8945 /// re-tuning bumps BOTH in lockstep (or the build breaks).
8946 #[test]
8947 fn confidence_tier_thresholds_match_model_constants() {
8948 let defaults = ConfidenceTierThresholds::default();
8949 assert!(
8950 (defaults.confirmed - crate::models::ConfidenceTier::CONFIRMED_MIN).abs()
8951 < f64::EPSILON,
8952 "ConfidenceTierThresholds.confirmed must match ConfidenceTier::CONFIRMED_MIN"
8953 );
8954 assert!(
8955 (defaults.likely - crate::models::ConfidenceTier::LIKELY_MIN).abs() < f64::EPSILON,
8956 "ConfidenceTierThresholds.likely must match ConfidenceTier::LIKELY_MIN"
8957 );
8958 // Ambiguous is the implicit floor — pin it to zero so the
8959 // wire shape is fully self-describing.
8960 assert!(
8961 (defaults.ambiguous - 0.0).abs() < f64::EPSILON,
8962 "ambiguous floor is fixed at 0.0"
8963 );
8964 }
8965
8966 /// v0.7.0 Gap 4 (#887) — every `TierConfig::capabilities()` call
8967 /// must surface the calibration block so MCP capability readers
8968 /// can rely on the field being present.
8969 #[test]
8970 fn capability_confidence_calibration_carries_tier_thresholds() {
8971 // `CapabilityConfidenceCalibration::current()` (the
8972 // capabilities v3 builder) surfaces the Gap 4 thresholds so
8973 // MCP capability readers can filter without re-deriving the
8974 // breakpoints.
8975 let surface = CapabilityConfidenceCalibration::current();
8976 assert!((surface.tier_thresholds.confirmed - 0.95).abs() < f64::EPSILON);
8977 assert!((surface.tier_thresholds.likely - 0.7).abs() < f64::EPSILON);
8978 assert!((surface.tier_thresholds.ambiguous - 0.0).abs() < f64::EPSILON);
8979 }
8980
8981 // ---------------------------------------------------------------------
8982 // v0.7.x enterprise-config tests (#1146)
8983 //
8984 // Pin: precedence ladder per resolver (CLI > env > config > legacy >
8985 // compiled), inline-key rejection at parse time, api_key_env /
8986 // api_key_file resolution, Once-gated legacy-drift WARN.
8987 // ---------------------------------------------------------------------
8988
8989 fn empty_app_config() -> AppConfig {
8990 AppConfig {
8991 schema_version: Some(2),
8992 ..AppConfig::default()
8993 }
8994 }
8995
8996 fn scrub_llm_env() {
8997 for k in [
8998 "AI_MEMORY_LLM_BACKEND",
8999 "AI_MEMORY_LLM_MODEL",
9000 "AI_MEMORY_LLM_BASE_URL",
9001 "AI_MEMORY_LLM_API_KEY",
9002 "XAI_API_KEY",
9003 "OPENAI_API_KEY",
9004 "ANTHROPIC_API_KEY",
9005 "GEMINI_API_KEY",
9006 "GOOGLE_API_KEY",
9007 "DEEPSEEK_API_KEY",
9008 "AI_MEMORY_EMBED_BACKFILL_BATCH",
9009 "AI_MEMORY_PASSPHRASE_FILE_ALLOW_LAX_PERMS",
9010 ] {
9011 unsafe {
9012 std::env::remove_var(k);
9013 }
9014 }
9015 }
9016
9017 /// #1598 — scrub the embeddings-resolver env surface (and the
9018 /// alias-fallback vendor key vars the precedence tests exercise)
9019 /// so `resolve_embeddings` tests are hermetic. Callers hold
9020 /// `env_var_lock()`.
9021 fn scrub_embed_env() {
9022 for k in [
9023 ENV_EMBED_BACKEND,
9024 ENV_EMBED_BASE_URL,
9025 ENV_EMBED_MODEL,
9026 ENV_EMBED_API_KEY,
9027 ENV_EMBED_BACKFILL_BATCH,
9028 "OPENROUTER_API_KEY",
9029 "GEMINI_API_KEY",
9030 "GOOGLE_API_KEY",
9031 ] {
9032 unsafe {
9033 std::env::remove_var(k);
9034 }
9035 }
9036 }
9037
9038 fn scrub_limits_env() {
9039 for k in [
9040 ENV_MAX_MEMORIES_PER_DAY,
9041 ENV_MAX_STORAGE_BYTES,
9042 ENV_MAX_LINKS_PER_DAY,
9043 ENV_MAX_PAGE_SIZE,
9044 ] {
9045 unsafe {
9046 std::env::remove_var(k);
9047 }
9048 }
9049 }
9050
9051 #[test]
9052 fn resolve_limits_compiled_default_when_nothing_configured() {
9053 let _g = env_var_lock();
9054 scrub_limits_env();
9055 let cfg = empty_app_config();
9056 let r = cfg.resolve_limits();
9057 assert_eq!(
9058 r.max_memories_per_day,
9059 crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
9060 );
9061 assert_eq!(
9062 r.max_storage_bytes,
9063 crate::quotas::DEFAULT_MAX_STORAGE_BYTES
9064 );
9065 assert_eq!(
9066 r.max_links_per_day,
9067 crate::quotas::DEFAULT_MAX_LINKS_PER_DAY
9068 );
9069 assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
9070 assert_eq!(r.source, ConfigSource::CompiledDefault);
9071 }
9072
9073 #[test]
9074 fn resolve_limits_config_section_when_no_env() {
9075 let _g = env_var_lock();
9076 scrub_limits_env();
9077 let mut cfg = empty_app_config();
9078 cfg.limits = Some(LimitsSection {
9079 max_memories_per_day: Some(5_000_000),
9080 max_storage_bytes: Some(9_000_000_000),
9081 max_links_per_day: Some(4_000_000),
9082 max_page_size: Some(250_000),
9083 });
9084 let r = cfg.resolve_limits();
9085 assert_eq!(r.max_memories_per_day, 5_000_000);
9086 assert_eq!(r.max_storage_bytes, 9_000_000_000);
9087 assert_eq!(r.max_links_per_day, 4_000_000);
9088 assert_eq!(r.max_page_size, 250_000);
9089 assert_eq!(r.source, ConfigSource::Config);
9090 }
9091
9092 #[test]
9093 fn resolve_limits_env_overrides_config_section() {
9094 let _g = env_var_lock();
9095 scrub_limits_env();
9096 unsafe {
9097 std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "7000000");
9098 std::env::set_var(ENV_MAX_PAGE_SIZE, "123456");
9099 }
9100 let mut cfg = empty_app_config();
9101 cfg.limits = Some(LimitsSection {
9102 max_memories_per_day: Some(5_000_000),
9103 max_storage_bytes: Some(9_000_000_000),
9104 max_links_per_day: Some(4_000_000),
9105 max_page_size: Some(250_000),
9106 });
9107 let r = cfg.resolve_limits();
9108 // env wins for the two it sets …
9109 assert_eq!(r.max_memories_per_day, 7_000_000, "env beats config");
9110 assert_eq!(r.max_page_size, 123_456, "env beats config");
9111 // … and config still supplies the fields env left unset.
9112 assert_eq!(r.max_storage_bytes, 9_000_000_000);
9113 assert_eq!(r.max_links_per_day, 4_000_000);
9114 assert_eq!(r.source, ConfigSource::Env);
9115 scrub_limits_env();
9116 }
9117
9118 #[test]
9119 fn resolve_limits_zero_and_garbage_env_fall_through() {
9120 let _g = env_var_lock();
9121 scrub_limits_env();
9122 unsafe {
9123 std::env::set_var(ENV_MAX_MEMORIES_PER_DAY, "0"); // non-positive → ignored
9124 std::env::set_var(ENV_MAX_STORAGE_BYTES, "not-a-number"); // unparseable → ignored
9125 std::env::set_var(ENV_MAX_PAGE_SIZE, "-5"); // negative → unparseable as usize → ignored
9126 }
9127 let cfg = empty_app_config();
9128 let r = cfg.resolve_limits();
9129 // every stray env value falls through to the compiled default.
9130 assert_eq!(
9131 r.max_memories_per_day,
9132 crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY
9133 );
9134 assert_eq!(
9135 r.max_storage_bytes,
9136 crate::quotas::DEFAULT_MAX_STORAGE_BYTES
9137 );
9138 assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
9139 assert_eq!(r.source, ConfigSource::CompiledDefault);
9140 scrub_limits_env();
9141 }
9142
9143 #[test]
9144 fn resolve_limits_zero_config_value_falls_through_to_default() {
9145 let _g = env_var_lock();
9146 scrub_limits_env();
9147 let mut cfg = empty_app_config();
9148 cfg.limits = Some(LimitsSection {
9149 max_page_size: Some(0), // non-positive → ignored
9150 ..LimitsSection::default()
9151 });
9152 let r = cfg.resolve_limits();
9153 assert_eq!(r.max_page_size, crate::handlers::MAX_BULK_SIZE);
9154 assert_eq!(r.source, ConfigSource::CompiledDefault);
9155 }
9156
9157 #[test]
9158 fn resolve_limits_section_round_trips_through_toml() {
9159 let toml = r#"
9160schema_version = 2
9161
9162[limits]
9163max_memories_per_day = 10000000
9164max_storage_bytes = 50000000000
9165max_links_per_day = 8000000
9166max_page_size = 1000000
9167"#;
9168 let cfg: AppConfig = toml::from_str(toml).expect("parse [limits] toml");
9169 let l = cfg.limits.as_ref().expect("limits section present");
9170 assert_eq!(l.max_memories_per_day, Some(10_000_000));
9171 assert_eq!(l.max_storage_bytes, Some(50_000_000_000));
9172 assert_eq!(l.max_links_per_day, Some(8_000_000));
9173 assert_eq!(l.max_page_size, Some(1_000_000));
9174 // env-free resolve picks up the config values verbatim.
9175 let _g = env_var_lock();
9176 scrub_limits_env();
9177 let r = cfg.resolve_limits();
9178 assert_eq!(r.max_memories_per_day, 10_000_000);
9179 assert_eq!(r.max_page_size, 1_000_000);
9180 assert_eq!(r.source, ConfigSource::Config);
9181 }
9182
9183 #[cfg(feature = "sal")]
9184 fn scrub_pg_pool_env() {
9185 for k in [
9186 ENV_PG_POOL_MAX,
9187 ENV_PG_POOL_MIN,
9188 ENV_PG_ACQUIRE_TIMEOUT_SECS,
9189 ] {
9190 unsafe {
9191 std::env::remove_var(k);
9192 }
9193 }
9194 }
9195
9196 #[cfg(feature = "sal")]
9197 #[test]
9198 fn resolve_pg_pool_compiled_default_when_nothing_configured() {
9199 let _g = env_var_lock();
9200 scrub_pg_pool_env();
9201 let cfg = empty_app_config();
9202 let r = cfg.resolve_pg_pool();
9203 assert_eq!(r, crate::store::PoolConfig::default());
9204 }
9205
9206 #[cfg(feature = "sal")]
9207 #[test]
9208 fn resolve_pg_pool_config_overrides_default() {
9209 let _g = env_var_lock();
9210 scrub_pg_pool_env();
9211 let mut cfg = empty_app_config();
9212 cfg.postgres_pool_max_connections = Some(64);
9213 cfg.postgres_pool_min_connections = Some(8);
9214 cfg.postgres_acquire_timeout_secs = Some(15);
9215 let r = cfg.resolve_pg_pool();
9216 assert_eq!(r.max_connections, 64);
9217 assert_eq!(r.min_connections, 8);
9218 assert_eq!(r.acquire_timeout_secs, 15);
9219 }
9220
9221 #[cfg(feature = "sal")]
9222 #[test]
9223 fn resolve_pg_pool_env_overrides_config() {
9224 let _g = env_var_lock();
9225 scrub_pg_pool_env();
9226 unsafe {
9227 std::env::set_var(ENV_PG_POOL_MAX, "100");
9228 std::env::set_var(ENV_PG_ACQUIRE_TIMEOUT_SECS, "45");
9229 }
9230 let mut cfg = empty_app_config();
9231 cfg.postgres_pool_max_connections = Some(64);
9232 cfg.postgres_pool_min_connections = Some(8);
9233 cfg.postgres_acquire_timeout_secs = Some(15);
9234 let r = cfg.resolve_pg_pool();
9235 // env wins for the two it sets …
9236 assert_eq!(r.max_connections, 100, "env beats config");
9237 assert_eq!(r.acquire_timeout_secs, 45, "env beats config");
9238 // … and config still supplies the field env left unset.
9239 assert_eq!(r.min_connections, 8);
9240 scrub_pg_pool_env();
9241 }
9242
9243 #[cfg(feature = "sal")]
9244 #[test]
9245 fn resolve_pg_pool_zero_and_garbage_fall_through() {
9246 let _g = env_var_lock();
9247 scrub_pg_pool_env();
9248 unsafe {
9249 std::env::set_var(ENV_PG_POOL_MAX, "0"); // non-positive → ignored
9250 std::env::set_var(ENV_PG_POOL_MIN, "not-a-number"); // unparseable → ignored
9251 }
9252 let mut cfg = empty_app_config();
9253 // A zero config value must also fall through, never clamp the pool.
9254 cfg.postgres_acquire_timeout_secs = Some(0);
9255 let r = cfg.resolve_pg_pool();
9256 // every stray value falls through to the compiled default.
9257 assert_eq!(r, crate::store::PoolConfig::default());
9258 scrub_pg_pool_env();
9259 }
9260
9261 #[cfg(feature = "sal")]
9262 #[test]
9263 fn pg_pool_env_const_names_byte_match_documented() {
9264 // Doc-name-match guard: these byte values are documented in
9265 // CLAUDE.md's Environment Variables table + the enterprise
9266 // deployment guide §5.6. Pin the drift so it can never recur.
9267 assert_eq!(ENV_PG_POOL_MAX, "AI_MEMORY_PG_POOL_MAX");
9268 assert_eq!(ENV_PG_POOL_MIN, "AI_MEMORY_PG_POOL_MIN");
9269 assert_eq!(
9270 ENV_PG_ACQUIRE_TIMEOUT_SECS,
9271 "AI_MEMORY_PG_ACQUIRE_TIMEOUT_SECS"
9272 );
9273 }
9274
9275 #[test]
9276 fn resolve_llm_1146_compiled_default_when_nothing_configured() {
9277 let _g = env_var_lock();
9278 scrub_llm_env();
9279 let cfg = empty_app_config();
9280 let resolved = cfg.resolve_llm(None, None, None);
9281 assert_eq!(resolved.backend, "ollama");
9282 assert_eq!(resolved.model, "gemma3:4b");
9283 assert_eq!(resolved.base_url, "http://localhost:11434");
9284 assert_eq!(resolved.source, ConfigSource::CompiledDefault);
9285 assert_eq!(resolved.api_key_source, KeySource::None);
9286 assert!(resolved.api_key().is_none());
9287 }
9288
9289 #[test]
9290 fn resolve_llm_1146_env_overrides_config_section() {
9291 let _g = env_var_lock();
9292 scrub_llm_env();
9293 unsafe {
9294 std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
9295 std::env::set_var("AI_MEMORY_LLM_MODEL", "grok-99");
9296 std::env::set_var("AI_MEMORY_LLM_API_KEY", "env-key");
9297 }
9298 let mut cfg = empty_app_config();
9299 cfg.llm = Some(LlmSection {
9300 backend: Some("openai".into()),
9301 model: Some("gpt-4".into()),
9302 ..LlmSection::default()
9303 });
9304 let resolved = cfg.resolve_llm(None, None, None);
9305 assert_eq!(resolved.backend, "xai", "env must beat config");
9306 assert_eq!(resolved.model, "grok-99");
9307 assert_eq!(resolved.source, ConfigSource::Env);
9308 assert_eq!(resolved.api_key_source, KeySource::ProcessEnv);
9309 assert_eq!(resolved.api_key(), Some("env-key"));
9310 scrub_llm_env();
9311 }
9312
9313 #[test]
9314 fn resolve_llm_1146_cli_overrides_env() {
9315 let _g = env_var_lock();
9316 scrub_llm_env();
9317 unsafe {
9318 std::env::set_var("AI_MEMORY_LLM_BACKEND", "ollama");
9319 std::env::set_var("AI_MEMORY_LLM_MODEL", "ollama-model");
9320 }
9321 let cfg = empty_app_config();
9322 let resolved = cfg.resolve_llm(Some("xai"), Some("grok-4.3"), Some("https://x"));
9323 assert_eq!(resolved.backend, "xai", "CLI flag must beat env");
9324 assert_eq!(resolved.model, "grok-4.3");
9325 assert_eq!(resolved.base_url, "https://x");
9326 assert_eq!(resolved.source, ConfigSource::Cli);
9327 scrub_llm_env();
9328 }
9329
9330 #[test]
9331 fn resolve_llm_1146_config_section_when_no_env() {
9332 let _g = env_var_lock();
9333 scrub_llm_env();
9334 let mut cfg = empty_app_config();
9335 cfg.llm = Some(LlmSection {
9336 backend: Some("xai".into()),
9337 model: Some("grok-4.3".into()),
9338 ..LlmSection::default()
9339 });
9340 let resolved = cfg.resolve_llm(None, None, None);
9341 assert_eq!(resolved.backend, "xai");
9342 assert_eq!(resolved.model, "grok-4.3");
9343 assert_eq!(
9344 resolved.base_url, "https://api.x.ai/v1",
9345 "vendor-default base_url applied"
9346 );
9347 assert_eq!(resolved.source, ConfigSource::Config);
9348 }
9349
9350 #[test]
9351 fn resolve_llm_1146_tier_model_override_clobbers_config_model_1440() {
9352 // #1440 regression: the pre-fix curator `--daemon` path passed
9353 // the feature-tier's default (local-Ollama) model id as the
9354 // CLI-arm model override. Because the CLI arm is highest
9355 // precedence, it clobbered the operator's configured
9356 // `[llm].model`, sending the local default to OpenRouter ->
9357 // fast HTTP 400 on every curator call. This test pins BOTH
9358 // halves of the RCA so the bug can't silently return:
9359 // 1. With no override (the `--once` / fixed `--daemon` path),
9360 // the configured model wins.
9361 // 2. Passing the tier-default id as the override DOES clobber
9362 // it — which is exactly why the daemon must never do so.
9363 let _g = env_var_lock();
9364 scrub_llm_env();
9365
9366 // Each value is bound once to a named variable (no repeated
9367 // literals, no magic strings in assertions). The tier-default
9368 // model is derived from the enum so the test tracks the single
9369 // source of truth rather than asserting against a copy.
9370 let configured_backend = "openrouter";
9371 let configured_model = "google/gemma-4-26b-a4b-it";
9372 let tier_default_model = crate::config::FeatureTier::Autonomous.config().llm_model;
9373
9374 let mut cfg = empty_app_config();
9375 cfg.llm = Some(LlmSection {
9376 backend: Some(configured_backend.into()),
9377 model: Some(configured_model.into()),
9378 ..LlmSection::default()
9379 });
9380
9381 // 1. No override -> configured model is honored.
9382 let resolved = cfg.resolve_llm(None, None, None);
9383 assert_eq!(resolved.backend, configured_backend);
9384 assert_eq!(resolved.model, configured_model);
9385
9386 // 2. Tier-default id as CLI-arm override clobbers it (the bug):
9387 // the override wins over the configured model, which is
9388 // exactly why the daemon must never manufacture one.
9389 let tier_override = tier_default_model.expect("autonomous tier has a default llm_model");
9390 let clobbered = cfg.resolve_llm(None, Some(tier_override.as_str()), None);
9391 assert_eq!(
9392 clobbered.model, tier_override,
9393 "tier-default override wins over configured model — the #1440 daemon defect"
9394 );
9395 assert_ne!(
9396 clobbered.model, configured_model,
9397 "the override must differ from the configured model for this regression to be meaningful"
9398 );
9399 scrub_llm_env();
9400 }
9401
9402 #[test]
9403 fn resolve_llm_1146_alias_fallback_key_for_xai() {
9404 let _g = env_var_lock();
9405 scrub_llm_env();
9406 unsafe {
9407 std::env::set_var("AI_MEMORY_LLM_BACKEND", "xai");
9408 std::env::set_var("XAI_API_KEY", "alias-fallback-key");
9409 }
9410 let cfg = empty_app_config();
9411 let resolved = cfg.resolve_llm(None, None, None);
9412 assert_eq!(resolved.backend, "xai");
9413 assert_eq!(resolved.api_key(), Some("alias-fallback-key"));
9414 match &resolved.api_key_source {
9415 KeySource::AliasFallback(name) => assert_eq!(name, "XAI_API_KEY"),
9416 other => panic!("expected AliasFallback(XAI_API_KEY), got {other:?}"),
9417 }
9418 scrub_llm_env();
9419 }
9420
9421 #[test]
9422 fn resolve_llm_1146_legacy_llm_model_feeds_resolver() {
9423 let _g = env_var_lock();
9424 scrub_llm_env();
9425 let mut cfg = AppConfig::default();
9426 cfg.llm_model = Some("gemma4:e4b".into());
9427 cfg.ollama_url = Some("http://localhost:11434".into());
9428 let resolved = cfg.resolve_llm(None, None, None);
9429 assert_eq!(resolved.backend, "ollama");
9430 assert_eq!(resolved.model, "gemma4:e4b");
9431 assert_eq!(resolved.source, ConfigSource::Legacy);
9432 }
9433
9434 #[test]
9435 fn validate_secret_handling_1146_rejects_inline_api_key() {
9436 let mut cfg = empty_app_config();
9437 cfg.llm = Some(LlmSection {
9438 backend: Some("xai".into()),
9439 api_key: Some("xai-INLINE-SECRET".into()),
9440 ..LlmSection::default()
9441 });
9442 let err = cfg
9443 .validate_secret_handling()
9444 .expect_err("inline api_key must be rejected");
9445 assert!(
9446 err.contains("api_key") && err.contains("forbidden"),
9447 "error must name the field and the policy: {err}"
9448 );
9449 }
9450
9451 #[test]
9452 fn validate_secret_handling_1146_rejects_env_and_file_both_set() {
9453 let mut cfg = empty_app_config();
9454 cfg.llm = Some(LlmSection {
9455 backend: Some("xai".into()),
9456 api_key_env: Some("XAI_API_KEY".into()),
9457 api_key_file: Some("/etc/key".into()),
9458 ..LlmSection::default()
9459 });
9460 let err = cfg
9461 .validate_secret_handling()
9462 .expect_err("env+file mutex must be enforced");
9463 assert!(
9464 err.contains("api_key_env") && err.contains("api_key_file"),
9465 "error must call out the mutex: {err}"
9466 );
9467 }
9468
9469 #[test]
9470 fn resolve_llm_1146_api_key_env_reads_named_env_var() {
9471 let _g = env_var_lock();
9472 scrub_llm_env();
9473 unsafe {
9474 std::env::set_var("MY_CUSTOM_LLM_KEY", "via-config-env-var");
9475 }
9476 let mut cfg = empty_app_config();
9477 cfg.llm = Some(LlmSection {
9478 backend: Some("xai".into()),
9479 model: Some("grok-4.3".into()),
9480 api_key_env: Some("MY_CUSTOM_LLM_KEY".into()),
9481 ..LlmSection::default()
9482 });
9483 let resolved = cfg.resolve_llm(None, None, None);
9484 assert_eq!(resolved.api_key(), Some("via-config-env-var"));
9485 match &resolved.api_key_source {
9486 KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_LLM_KEY"),
9487 other => panic!("expected ConfigEnvVar(MY_CUSTOM_LLM_KEY), got {other:?}"),
9488 }
9489 unsafe {
9490 std::env::remove_var("MY_CUSTOM_LLM_KEY");
9491 }
9492 }
9493
9494 #[test]
9495 #[cfg(unix)]
9496 fn resolve_llm_1146_api_key_file_rejects_lax_perms() {
9497 use std::os::unix::fs::PermissionsExt;
9498 let _g = env_var_lock();
9499 scrub_llm_env();
9500 // Tempdir under .local-runs (project HARD rule: no /tmp).
9501 let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9502 .join(".local-runs")
9503 .join(format!("test-1146-perms-{}", std::process::id()));
9504 std::fs::create_dir_all(&base).unwrap();
9505 let key_path = base.join("xai.key");
9506 std::fs::write(&key_path, "shhh").unwrap();
9507 // World-readable mode 0644 — must be rejected.
9508 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
9509
9510 let mut cfg = empty_app_config();
9511 cfg.llm = Some(LlmSection {
9512 backend: Some("xai".into()),
9513 api_key_file: Some(key_path.display().to_string()),
9514 ..LlmSection::default()
9515 });
9516 let resolved = cfg.resolve_llm(None, None, None);
9517 match &resolved.api_key_source {
9518 KeySource::Error(reason) => {
9519 assert!(
9520 reason.contains("lax permissions") && reason.contains("0400"),
9521 "error must name the perm policy: {reason}"
9522 );
9523 }
9524 other => panic!("expected KeySource::Error(lax perms), got {other:?}"),
9525 }
9526 // Cleanup.
9527 let _ = std::fs::remove_file(&key_path);
9528 let _ = std::fs::remove_dir(&base);
9529 }
9530
9531 #[test]
9532 #[cfg(unix)]
9533 fn resolve_llm_1146_api_key_file_accepts_0400() {
9534 use std::os::unix::fs::PermissionsExt;
9535 let _g = env_var_lock();
9536 scrub_llm_env();
9537 let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9538 .join(".local-runs")
9539 .join(format!("test-1146-perms-ok-{}", std::process::id()));
9540 std::fs::create_dir_all(&base).unwrap();
9541 let key_path = base.join("xai.key");
9542 std::fs::write(&key_path, "the-actual-key\n").unwrap();
9543 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o400)).unwrap();
9544
9545 let mut cfg = empty_app_config();
9546 cfg.llm = Some(LlmSection {
9547 backend: Some("xai".into()),
9548 api_key_file: Some(key_path.display().to_string()),
9549 ..LlmSection::default()
9550 });
9551 let resolved = cfg.resolve_llm(None, None, None);
9552 assert_eq!(
9553 resolved.api_key(),
9554 Some("the-actual-key"),
9555 "first line is the key"
9556 );
9557 assert!(matches!(resolved.api_key_source, KeySource::ConfigFile(_)));
9558
9559 let _ = std::fs::remove_file(&key_path);
9560 let _ = std::fs::remove_dir(&base);
9561 }
9562
9563 #[test]
9564 fn resolve_embeddings_1146_legacy_alias_canonicalised() {
9565 let _g = env_var_lock();
9566 scrub_llm_env();
9567 let mut cfg = AppConfig::default();
9568 cfg.embedding_model = Some("nomic_embed_v15".into());
9569 let resolved = cfg.resolve_embeddings();
9570 assert_eq!(
9571 resolved.model, "nomic-embed-text-v1.5",
9572 "legacy alias must be canonicalised"
9573 );
9574 assert_eq!(resolved.source, ConfigSource::Legacy);
9575 assert_eq!(resolved.backfill_batch, 100, "compiled default applied");
9576 }
9577
9578 #[test]
9579 fn resolve_embeddings_1146_backfill_batch_env_overrides_config() {
9580 let _g = env_var_lock();
9581 scrub_llm_env();
9582 unsafe {
9583 std::env::set_var("AI_MEMORY_EMBED_BACKFILL_BATCH", "500");
9584 }
9585 let mut cfg = empty_app_config();
9586 cfg.embeddings = Some(EmbeddingsSection {
9587 backfill_batch: Some(50),
9588 ..EmbeddingsSection::default()
9589 });
9590 let resolved = cfg.resolve_embeddings();
9591 assert_eq!(resolved.backfill_batch, 500, "env must beat config");
9592 scrub_llm_env();
9593 }
9594
9595 // ── #1598 — API-wired embeddings resolver ladder ──────────────────
9596
9597 #[test]
9598 fn resolve_embeddings_1598_compiled_defaults() {
9599 let _g = env_var_lock();
9600 scrub_llm_env();
9601 scrub_embed_env();
9602 let cfg = empty_app_config();
9603 let resolved = cfg.resolve_embeddings();
9604 assert_eq!(resolved.backend, crate::llm::BACKEND_OLLAMA);
9605 assert_eq!(resolved.url, crate::llm::DEFAULT_OLLAMA_URL);
9606 assert_eq!(resolved.model, DEFAULT_EMBED_MODEL);
9607 assert_eq!(resolved.source, ConfigSource::CompiledDefault);
9608 assert_eq!(resolved.api_key(), None);
9609 assert_eq!(resolved.key_source, KeySource::None);
9610 }
9611
9612 #[test]
9613 fn resolve_embeddings_1598_env_beats_section() {
9614 let _g = env_var_lock();
9615 scrub_llm_env();
9616 scrub_embed_env();
9617 unsafe {
9618 std::env::set_var(ENV_EMBED_BACKEND, "openai-compatible");
9619 std::env::set_var(ENV_EMBED_BASE_URL, "http://tei.internal:8080/v1");
9620 std::env::set_var(
9621 ENV_EMBED_MODEL,
9622 "ibm-granite/granite-embedding-125m-english",
9623 );
9624 }
9625 let mut cfg = empty_app_config();
9626 cfg.embeddings = Some(EmbeddingsSection {
9627 backend: Some("ollama".into()),
9628 url: Some("http://section-url:11434".into()),
9629 model: Some("nomic-embed-text-v1.5".into()),
9630 ..EmbeddingsSection::default()
9631 });
9632 let resolved = cfg.resolve_embeddings();
9633 assert_eq!(resolved.backend, "openai-compatible");
9634 assert_eq!(resolved.url, "http://tei.internal:8080/v1");
9635 assert_eq!(resolved.model, "ibm-granite/granite-embedding-125m-english");
9636 assert_eq!(resolved.source, ConfigSource::Env);
9637 assert_eq!(
9638 resolved.embedding_dim,
9639 Some(768),
9640 "granite dim comes from the known-dims table"
9641 );
9642 scrub_embed_env();
9643 }
9644
9645 #[test]
9646 fn resolve_embeddings_1598_section_beats_legacy() {
9647 let _g = env_var_lock();
9648 scrub_llm_env();
9649 scrub_embed_env();
9650 let mut cfg = empty_app_config();
9651 cfg.embed_url = Some("http://legacy-embed:11434".into());
9652 cfg.embedding_model = Some("mini_lm_l6_v2".into());
9653 cfg.embeddings = Some(EmbeddingsSection {
9654 url: Some("http://section:11434".into()),
9655 model: Some("nomic-embed-text-v1.5".into()),
9656 ..EmbeddingsSection::default()
9657 });
9658 let resolved = cfg.resolve_embeddings();
9659 assert_eq!(resolved.url, "http://section:11434");
9660 assert_eq!(resolved.model, "nomic-embed-text-v1.5");
9661 assert_eq!(resolved.source, ConfigSource::Config);
9662 }
9663
9664 #[test]
9665 fn resolve_embeddings_1598_base_url_wins_over_url_synonym() {
9666 let _g = env_var_lock();
9667 scrub_llm_env();
9668 scrub_embed_env();
9669 let mut cfg = empty_app_config();
9670 cfg.embeddings = Some(EmbeddingsSection {
9671 base_url: Some("http://base-url-wins:8080/v1".into()),
9672 url: Some("http://url-loses:11434".into()),
9673 ..EmbeddingsSection::default()
9674 });
9675 let resolved = cfg.resolve_embeddings();
9676 assert_eq!(resolved.url, "http://base-url-wins:8080/v1");
9677 }
9678
9679 #[test]
9680 fn resolve_embeddings_1598_api_alias_default_base_url() {
9681 let _g = env_var_lock();
9682 scrub_llm_env();
9683 scrub_embed_env();
9684 let mut cfg = empty_app_config();
9685 cfg.embeddings = Some(EmbeddingsSection {
9686 backend: Some("openrouter".into()),
9687 model: Some("google/gemini-embedding-2".into()),
9688 ..EmbeddingsSection::default()
9689 });
9690 let resolved = cfg.resolve_embeddings();
9691 assert_eq!(
9692 resolved.url, "https://openrouter.ai/api/v1",
9693 "API alias with no URL configured must fall back to the \
9694 vendor default from llm.rs"
9695 );
9696 assert_eq!(resolved.embedding_dim, Some(3072), "gemini-embedding-2 dim");
9697 }
9698
9699 #[test]
9700 fn resolve_embeddings_1598_dim_override_beats_table() {
9701 let _g = env_var_lock();
9702 scrub_llm_env();
9703 scrub_embed_env();
9704 let mut cfg = empty_app_config();
9705 cfg.embeddings = Some(EmbeddingsSection {
9706 model: Some("nomic-embed-text-v1.5".into()),
9707 dim: Some(512),
9708 ..EmbeddingsSection::default()
9709 });
9710 let resolved = cfg.resolve_embeddings();
9711 assert_eq!(
9712 resolved.embedding_dim,
9713 Some(512),
9714 "[embeddings].dim override must beat the known-dims table"
9715 );
9716 // Non-positive override is ignored — table wins again.
9717 cfg.embeddings = Some(EmbeddingsSection {
9718 model: Some("nomic-embed-text-v1.5".into()),
9719 dim: Some(0),
9720 ..EmbeddingsSection::default()
9721 });
9722 assert_eq!(cfg.resolve_embeddings().embedding_dim, Some(768));
9723 }
9724
9725 /// #1598 fleet follow-up — `requested_dim` carries ONLY the
9726 /// explicit `[embeddings].dim` (the wire `dimensions` request for
9727 /// Matryoshka-capable API models); a table-derived dim must never
9728 /// populate it, and non-positive overrides are ignored.
9729 #[test]
9730 fn resolve_embeddings_1598_requested_dim_explicit_only() {
9731 let _g = env_var_lock();
9732 scrub_llm_env();
9733 scrub_embed_env();
9734 let mut cfg = empty_app_config();
9735 // Table-known model, no explicit dim → requested_dim None.
9736 cfg.embeddings = Some(EmbeddingsSection {
9737 model: Some("nomic-embed-text-v1.5".into()),
9738 ..EmbeddingsSection::default()
9739 });
9740 let resolved = cfg.resolve_embeddings();
9741 assert_eq!(resolved.embedding_dim, Some(768), "table dim resolves");
9742 assert_eq!(
9743 resolved.requested_dim, None,
9744 "table-derived dim must not become a wire dimensions request"
9745 );
9746 // Explicit dim → both embedding_dim and requested_dim.
9747 cfg.embeddings = Some(EmbeddingsSection {
9748 model: Some("google/gemini-embedding-2".into()),
9749 dim: Some(768),
9750 ..EmbeddingsSection::default()
9751 });
9752 let resolved = cfg.resolve_embeddings();
9753 assert_eq!(resolved.embedding_dim, Some(768));
9754 assert_eq!(resolved.requested_dim, Some(768));
9755 // Non-positive explicit dim is ignored on both fields.
9756 cfg.embeddings = Some(EmbeddingsSection {
9757 model: Some("google/gemini-embedding-2".into()),
9758 dim: Some(0),
9759 ..EmbeddingsSection::default()
9760 });
9761 let resolved = cfg.resolve_embeddings();
9762 assert_eq!(resolved.embedding_dim, Some(3072), "table dim again");
9763 assert_eq!(resolved.requested_dim, None);
9764 }
9765
9766 #[test]
9767 fn resolve_embed_api_key_1598_process_env_wins() {
9768 let _g = env_var_lock();
9769 scrub_llm_env();
9770 scrub_embed_env();
9771 unsafe {
9772 std::env::set_var(ENV_EMBED_API_KEY, "embed-process-env-key");
9773 std::env::set_var("OPENROUTER_API_KEY", "alias-key-loses");
9774 }
9775 let mut cfg = empty_app_config();
9776 cfg.embeddings = Some(EmbeddingsSection {
9777 backend: Some("openrouter".into()),
9778 ..EmbeddingsSection::default()
9779 });
9780 let resolved = cfg.resolve_embeddings();
9781 assert_eq!(resolved.api_key(), Some("embed-process-env-key"));
9782 assert_eq!(resolved.key_source, KeySource::ProcessEnv);
9783 scrub_embed_env();
9784 }
9785
9786 #[test]
9787 fn resolve_embed_api_key_1598_alias_fallback() {
9788 let _g = env_var_lock();
9789 scrub_llm_env();
9790 scrub_embed_env();
9791 unsafe {
9792 std::env::set_var("OPENROUTER_API_KEY", "alias-fallback-embed-key");
9793 }
9794 let mut cfg = empty_app_config();
9795 cfg.embeddings = Some(EmbeddingsSection {
9796 backend: Some("openrouter".into()),
9797 ..EmbeddingsSection::default()
9798 });
9799 let resolved = cfg.resolve_embeddings();
9800 assert_eq!(resolved.api_key(), Some("alias-fallback-embed-key"));
9801 match &resolved.key_source {
9802 KeySource::AliasFallback(name) => assert_eq!(name, "OPENROUTER_API_KEY"),
9803 other => panic!("expected AliasFallback(OPENROUTER_API_KEY), got {other:?}"),
9804 }
9805 scrub_embed_env();
9806 }
9807
9808 #[test]
9809 fn resolve_embed_api_key_1598_config_env_var() {
9810 let _g = env_var_lock();
9811 scrub_llm_env();
9812 scrub_embed_env();
9813 unsafe {
9814 std::env::set_var("MY_CUSTOM_EMBED_KEY", "via-embed-config-env-var");
9815 }
9816 let mut cfg = empty_app_config();
9817 cfg.embeddings = Some(EmbeddingsSection {
9818 backend: Some("openai-compatible".into()),
9819 api_key_env: Some("MY_CUSTOM_EMBED_KEY".into()),
9820 ..EmbeddingsSection::default()
9821 });
9822 let resolved = cfg.resolve_embeddings();
9823 assert_eq!(resolved.api_key(), Some("via-embed-config-env-var"));
9824 match &resolved.key_source {
9825 KeySource::ConfigEnvVar(name) => assert_eq!(name, "MY_CUSTOM_EMBED_KEY"),
9826 other => panic!("expected ConfigEnvVar(MY_CUSTOM_EMBED_KEY), got {other:?}"),
9827 }
9828 unsafe {
9829 std::env::remove_var("MY_CUSTOM_EMBED_KEY");
9830 }
9831 }
9832
9833 #[test]
9834 #[cfg(unix)]
9835 fn resolve_embed_api_key_1598_api_key_file_rejects_lax_perms() {
9836 use std::os::unix::fs::PermissionsExt;
9837 let _g = env_var_lock();
9838 scrub_llm_env();
9839 scrub_embed_env();
9840 let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9841 .join(".local-runs")
9842 .join(format!("test-1598-perms-lax-{}", std::process::id()));
9843 std::fs::create_dir_all(&base).unwrap();
9844 let key_path = base.join("embed.key");
9845 std::fs::write(&key_path, "leaky-embed-key\n").unwrap();
9846 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
9847
9848 let mut cfg = empty_app_config();
9849 cfg.embeddings = Some(EmbeddingsSection {
9850 backend: Some("openai-compatible".into()),
9851 api_key_file: Some(key_path.display().to_string()),
9852 ..EmbeddingsSection::default()
9853 });
9854 let resolved = cfg.resolve_embeddings();
9855 assert_eq!(resolved.api_key(), None, "lax-perm file must be refused");
9856 match &resolved.key_source {
9857 KeySource::Error(reason) => {
9858 assert!(
9859 reason.contains("[embeddings].api_key_file") && reason.contains("lax"),
9860 "error must attribute the embeddings field: {reason}"
9861 );
9862 }
9863 other => panic!("expected KeySource::Error, got {other:?}"),
9864 }
9865
9866 let _ = std::fs::remove_file(&key_path);
9867 let _ = std::fs::remove_dir(&base);
9868 }
9869
9870 #[test]
9871 fn resolved_embeddings_1598_debug_redacts_api_key() {
9872 let _g = env_var_lock();
9873 scrub_llm_env();
9874 scrub_embed_env();
9875 unsafe {
9876 std::env::set_var(ENV_EMBED_API_KEY, "super-secret-embed-key");
9877 }
9878 let mut cfg = empty_app_config();
9879 cfg.embeddings = Some(EmbeddingsSection {
9880 backend: Some("openrouter".into()),
9881 ..EmbeddingsSection::default()
9882 });
9883 let resolved = cfg.resolve_embeddings();
9884 let debugged = format!("{resolved:?}");
9885 assert!(
9886 !debugged.contains("super-secret-embed-key"),
9887 "Debug must never leak the key: {debugged}"
9888 );
9889 assert!(
9890 debugged.contains(crate::REDACTED_PLACEHOLDER),
9891 "Debug must show the redaction placeholder: {debugged}"
9892 );
9893 scrub_embed_env();
9894 }
9895
9896 #[test]
9897 fn validate_secret_handling_1598_rejects_inline_embeddings_api_key() {
9898 let mut cfg = empty_app_config();
9899 cfg.embeddings = Some(EmbeddingsSection {
9900 backend: Some("openrouter".into()),
9901 api_key: Some("embed-INLINE-SECRET".into()),
9902 ..EmbeddingsSection::default()
9903 });
9904 let err = cfg
9905 .validate_secret_handling()
9906 .expect_err("inline [embeddings].api_key must be rejected");
9907 assert!(
9908 err.contains("api_key") && err.contains("forbidden") && err.contains("[embeddings]"),
9909 "error must name the field, section, and policy: {err}"
9910 );
9911 }
9912
9913 #[test]
9914 fn validate_secret_handling_1598_rejects_embeddings_env_and_file_both_set() {
9915 let mut cfg = empty_app_config();
9916 cfg.embeddings = Some(EmbeddingsSection {
9917 api_key_env: Some("EMBED_KEY".into()),
9918 api_key_file: Some("/etc/embed.key".into()),
9919 ..EmbeddingsSection::default()
9920 });
9921 let err = cfg
9922 .validate_secret_handling()
9923 .expect_err("[embeddings] env+file mutex must be enforced");
9924 assert!(
9925 err.contains("[embeddings].api_key_env") && err.contains("[embeddings].api_key_file"),
9926 "error must call out the mutex: {err}"
9927 );
9928 }
9929
9930 #[test]
9931 fn is_api_embed_backend_1598_classification() {
9932 // "ollama" is the ONLY non-API backend (case/space tolerant).
9933 assert!(!is_api_embed_backend(crate::llm::BACKEND_OLLAMA));
9934 assert!(!is_api_embed_backend(" Ollama "));
9935 // Every #1067 alias + the generic escape hatch is an API backend.
9936 for api in ["openrouter", "openai", "gemini", "openai-compatible"] {
9937 assert!(is_api_embed_backend(api), "{api} must classify as API");
9938 }
9939 }
9940
9941 #[test]
9942 fn known_embedding_dims_1598_gemini_and_granite_entries() {
9943 assert_eq!(
9944 canonical_embedding_dim("google/gemini-embedding-2"),
9945 Some(3072)
9946 );
9947 assert_eq!(canonical_embedding_dim("gemini-embedding-2"), Some(3072));
9948 assert_eq!(
9949 canonical_embedding_dim("ibm-granite/granite-embedding-125m-english"),
9950 Some(768)
9951 );
9952 assert_eq!(canonical_embedding_dim("granite-embedding"), Some(768));
9953 }
9954
9955 // ── #1579 B7 — `[storage].db_mmap_size_bytes` / AI_MEMORY_DB_MMAP_SIZE ──
9956
9957 #[test]
9958 fn resolve_storage_1579_mmap_compiled_default() {
9959 let _g = env_var_lock();
9960 unsafe {
9961 std::env::remove_var(ENV_DB_MMAP_SIZE);
9962 }
9963 let cfg = empty_app_config();
9964 let resolved = cfg.resolve_storage();
9965 assert_eq!(
9966 resolved.db_mmap_size_bytes,
9967 crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
9968 "no env + no section must bottom out on the compiled 256 MiB default"
9969 );
9970 }
9971
9972 #[test]
9973 fn resolve_storage_1579_mmap_env_overrides_config() {
9974 let _g = env_var_lock();
9975 unsafe {
9976 std::env::set_var(ENV_DB_MMAP_SIZE, "1048576");
9977 }
9978 let mut cfg = empty_app_config();
9979 cfg.storage = Some(StorageSection {
9980 db_mmap_size_bytes: Some(2_097_152),
9981 ..StorageSection::default()
9982 });
9983 let resolved = cfg.resolve_storage();
9984 assert_eq!(
9985 resolved.db_mmap_size_bytes, 1_048_576,
9986 "env must beat the [storage] section"
9987 );
9988 unsafe {
9989 std::env::remove_var(ENV_DB_MMAP_SIZE);
9990 }
9991 }
9992
9993 #[test]
9994 fn resolve_storage_1579_mmap_config_zero_disables() {
9995 let _g = env_var_lock();
9996 unsafe {
9997 std::env::remove_var(ENV_DB_MMAP_SIZE);
9998 }
9999 let mut cfg = empty_app_config();
10000 cfg.storage = Some(StorageSection {
10001 db_mmap_size_bytes: Some(0),
10002 ..StorageSection::default()
10003 });
10004 let resolved = cfg.resolve_storage();
10005 assert_eq!(
10006 resolved.db_mmap_size_bytes, 0,
10007 "explicit 0 (mmap disabled) is a deliberate operator choice and must be honoured"
10008 );
10009 }
10010
10011 #[test]
10012 fn resolve_storage_1579_mmap_garbage_falls_through() {
10013 let _g = env_var_lock();
10014 unsafe {
10015 std::env::set_var(ENV_DB_MMAP_SIZE, "not-a-number");
10016 }
10017 let mut cfg = empty_app_config();
10018 cfg.storage = Some(StorageSection {
10019 db_mmap_size_bytes: Some(-5),
10020 ..StorageSection::default()
10021 });
10022 let resolved = cfg.resolve_storage();
10023 assert_eq!(
10024 resolved.db_mmap_size_bytes,
10025 crate::storage::DEFAULT_DB_MMAP_SIZE_BYTES,
10026 "unparseable env + negative section value must both fall through to the compiled default"
10027 );
10028 unsafe {
10029 std::env::remove_var(ENV_DB_MMAP_SIZE);
10030 }
10031 }
10032
10033 // ── #1590 — `[storage].default_namespace` explicit-vs-compiled provenance ──
10034
10035 /// #1590 regression — `resolve_storage` distinguishes an EXPLICIT
10036 /// operator `default_namespace` (section or legacy flat field)
10037 /// from the compiled `"global"` fallback, and
10038 /// `explicit_default_namespace()` only reports the former.
10039 #[test]
10040 fn resolve_storage_default_namespace_provenance_1590() {
10041 let _g = env_var_lock();
10042 // Unconfigured: compiled default, NOT explicit.
10043 let cfg = empty_app_config();
10044 let resolved = cfg.resolve_storage();
10045 assert_eq!(resolved.default_namespace, crate::DEFAULT_NAMESPACE);
10046 assert_eq!(
10047 resolved.default_namespace_source,
10048 ConfigSource::CompiledDefault
10049 );
10050 assert_eq!(resolved.explicit_default_namespace(), None);
10051
10052 // A [storage] section WITHOUT default_namespace is still NOT
10053 // explicit (the section-level `source` tag says Config, which
10054 // is exactly why the per-field tag exists).
10055 let mut cfg = empty_app_config();
10056 cfg.storage = Some(StorageSection {
10057 archive_on_gc: Some(true),
10058 ..StorageSection::default()
10059 });
10060 let resolved = cfg.resolve_storage();
10061 assert_eq!(resolved.explicit_default_namespace(), None);
10062 assert_eq!(
10063 resolved.default_namespace_source,
10064 ConfigSource::CompiledDefault
10065 );
10066
10067 // Explicit [storage].default_namespace → Config provenance.
10068 let mut cfg = empty_app_config();
10069 cfg.storage = Some(StorageSection {
10070 default_namespace: Some("alphaone".to_string()),
10071 ..StorageSection::default()
10072 });
10073 let resolved = cfg.resolve_storage();
10074 assert_eq!(resolved.default_namespace, "alphaone");
10075 assert_eq!(resolved.default_namespace_source, ConfigSource::Config);
10076 assert_eq!(resolved.explicit_default_namespace(), Some("alphaone"));
10077
10078 // Legacy flat field → Legacy provenance, still explicit.
10079 #[allow(deprecated)]
10080 let resolved = {
10081 let mut cfg = empty_app_config();
10082 cfg.default_namespace = Some("legacy-ns".to_string());
10083 cfg.resolve_storage()
10084 };
10085 assert_eq!(resolved.default_namespace, "legacy-ns");
10086 assert_eq!(resolved.default_namespace_source, ConfigSource::Legacy);
10087 assert_eq!(resolved.explicit_default_namespace(), Some("legacy-ns"));
10088
10089 // Whitespace-only is treated as unset (not explicit).
10090 let mut cfg = empty_app_config();
10091 cfg.storage = Some(StorageSection {
10092 default_namespace: Some(" ".to_string()),
10093 ..StorageSection::default()
10094 });
10095 let resolved = cfg.resolve_storage();
10096 assert_eq!(resolved.explicit_default_namespace(), None);
10097 }
10098
10099 /// #1590 regression — the process-wide seeded slot round-trips,
10100 /// filters blank values, and clears back to the unconfigured state.
10101 #[test]
10102 fn configured_default_namespace_seed_and_clear_1590() {
10103 let _gate = lock_configured_default_namespace_for_test();
10104 set_configured_default_namespace(Some("alphaone".to_string()));
10105 assert_eq!(
10106 configured_default_namespace().as_deref(),
10107 Some("alphaone"),
10108 "seeded value must be readable process-wide"
10109 );
10110 set_configured_default_namespace(Some(" ".to_string()));
10111 assert_eq!(
10112 configured_default_namespace(),
10113 None,
10114 "blank seeds are filtered to the unconfigured state"
10115 );
10116 set_configured_default_namespace(Some("ns2".to_string()));
10117 set_configured_default_namespace(None);
10118 assert_eq!(configured_default_namespace(), None, "clear resets");
10119 }
10120
10121 #[test]
10122 fn resolve_reranker_1146_folds_legacy_cross_encoder() {
10123 let _g = env_var_lock();
10124 let mut cfg = AppConfig::default();
10125 cfg.cross_encoder = Some(true);
10126 let resolved = cfg.resolve_reranker();
10127 assert!(resolved.enabled);
10128 assert_eq!(resolved.model, "ms-marco-MiniLM-L-6-v2");
10129 assert_eq!(resolved.source, ConfigSource::Legacy);
10130 }
10131
10132 #[test]
10133 fn curator_reflection_namespace_enabled_1671() {
10134 use std::collections::HashMap;
10135 // No [curator] section → conservative default false (the
10136 // pre-#1671 inert-but-safe --all-namespaces posture).
10137 let bare = AppConfig::default();
10138 assert!(!bare.reflection_namespace_enabled("team/eng"));
10139
10140 let mut ns_map = HashMap::new();
10141 ns_map.insert(
10142 "team/eng".to_string(),
10143 crate::curator::reflection_pass::ReflectionPassConfig {
10144 enabled: true,
10145 max_depth: None,
10146 },
10147 );
10148 ns_map.insert(
10149 "team/ops".to_string(),
10150 crate::curator::reflection_pass::ReflectionPassConfig {
10151 enabled: false,
10152 max_depth: None,
10153 },
10154 );
10155 let cfg = AppConfig {
10156 curator: Some(CuratorSection {
10157 reflection_namespaces: Some(ns_map),
10158 confidence_decay_half_life_days: None,
10159 }),
10160 ..AppConfig::default()
10161 };
10162 assert!(
10163 cfg.reflection_namespace_enabled("team/eng"),
10164 "#1671: enabled=true namespace participates"
10165 );
10166 assert!(
10167 !cfg.reflection_namespace_enabled("team/ops"),
10168 "#1671: enabled=false namespace is skipped"
10169 );
10170 assert!(
10171 !cfg.reflection_namespace_enabled("team/unlisted"),
10172 "#1671: namespace with no entry is skipped"
10173 );
10174 }
10175
10176 #[test]
10177 fn curator_confidence_decay_half_life_resolver_n15() {
10178 use std::collections::HashMap;
10179 // No config → compiled default.
10180 let bare = AppConfig::default();
10181 assert!(
10182 (bare.confidence_decay_half_life_for("team/eng")
10183 - crate::confidence::DEFAULT_HALF_LIFE_DAYS)
10184 .abs()
10185 < f64::EPSILON
10186 );
10187
10188 let mut hl = HashMap::new();
10189 hl.insert("team/eng".to_string(), 14.0_f64);
10190 hl.insert("team/bad".to_string(), -5.0_f64); // non-positive → falls through
10191 hl.insert("team/nan".to_string(), f64::NAN); // non-finite → falls through
10192 let cfg = AppConfig {
10193 curator: Some(CuratorSection {
10194 reflection_namespaces: None,
10195 confidence_decay_half_life_days: Some(hl),
10196 }),
10197 ..AppConfig::default()
10198 };
10199 assert!((cfg.confidence_decay_half_life_for("team/eng") - 14.0).abs() < f64::EPSILON);
10200 assert!(
10201 (cfg.confidence_decay_half_life_for("team/bad")
10202 - crate::confidence::DEFAULT_HALF_LIFE_DAYS)
10203 .abs()
10204 < f64::EPSILON,
10205 "n15: non-positive override falls through to the default"
10206 );
10207 assert!(
10208 (cfg.confidence_decay_half_life_for("team/nan")
10209 - crate::confidence::DEFAULT_HALF_LIFE_DAYS)
10210 .abs()
10211 < f64::EPSILON,
10212 "n15: non-finite override falls through to the default"
10213 );
10214 // The boot-seed snapshot keeps only the admissible entries.
10215 let snap = cfg.confidence_decay_half_life_overrides();
10216 assert_eq!(snap.len(), 1, "only the finite positive entry survives");
10217 assert!((snap["team/eng"] - 14.0).abs() < f64::EPSILON);
10218 }
10219
10220 /// #1604 — rerank sequence-cap ladder: env >
10221 /// `[reranker].max_seq_tokens` > compiled default, with zero /
10222 /// unparseable / above-model-ceiling values falling through.
10223 #[test]
10224 fn resolve_reranker_1604_max_seq_ladder() {
10225 let _g = env_var_lock();
10226 unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10227
10228 // Compiled default when nothing is configured.
10229 let cfg = AppConfig::default();
10230 assert_eq!(
10231 cfg.resolve_reranker().max_seq_tokens,
10232 crate::reranker::RERANK_MAX_SEQ_DEFAULT
10233 );
10234
10235 // Config layer wins over the compiled default.
10236 let mut cfg = AppConfig::default();
10237 cfg.reranker = Some(RerankerSection {
10238 max_seq_tokens: Some(128),
10239 ..RerankerSection::default()
10240 });
10241 assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10242
10243 // Env wins over config.
10244 unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "192") };
10245 assert_eq!(cfg.resolve_reranker().max_seq_tokens, 192);
10246
10247 // Garbage env falls through to config.
10248 unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "not-a-number") };
10249 assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10250
10251 // Zero env falls through to config.
10252 unsafe { std::env::set_var(ENV_RERANK_MAX_SEQ, "0") };
10253 assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10254
10255 // Above the model ceiling falls through to config.
10256 unsafe {
10257 std::env::set_var(
10258 ENV_RERANK_MAX_SEQ,
10259 (crate::reranker::CROSS_ENCODER_MAX_SEQ + 1).to_string(),
10260 );
10261 }
10262 assert_eq!(cfg.resolve_reranker().max_seq_tokens, 128);
10263
10264 // Above-ceiling CONFIG value falls through to the compiled
10265 // default (no admissible layer remains).
10266 unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10267 let mut cfg = AppConfig::default();
10268 cfg.reranker = Some(RerankerSection {
10269 max_seq_tokens: Some(crate::reranker::CROSS_ENCODER_MAX_SEQ + 1),
10270 ..RerankerSection::default()
10271 });
10272 assert_eq!(
10273 cfg.resolve_reranker().max_seq_tokens,
10274 crate::reranker::RERANK_MAX_SEQ_DEFAULT
10275 );
10276
10277 unsafe { std::env::remove_var(ENV_RERANK_MAX_SEQ) };
10278 }
10279
10280 #[test]
10281 fn resolved_llm_1146_debug_redacts_api_key() {
10282 let resolved = ResolvedLlm {
10283 backend: "xai".into(),
10284 model: "grok-4.3".into(),
10285 base_url: "https://api.x.ai/v1".into(),
10286 api_key: Some("SUPER-SECRET-DONT-LEAK".into()),
10287 api_key_source: KeySource::ProcessEnv,
10288 source: ConfigSource::Env,
10289 };
10290 let dbg = format!("{resolved:?}");
10291 assert!(
10292 !dbg.contains("SUPER-SECRET-DONT-LEAK"),
10293 "Debug impl must redact the api_key: {dbg}"
10294 );
10295 assert!(
10296 dbg.contains("<redacted>"),
10297 "Debug impl must show <redacted> placeholder: {dbg}"
10298 );
10299 }
10300
10301 /// #1454 (SEC, LOW) — a `{:?}` of an `AppConfig` carrying the HTTP
10302 /// `api_key` MUST NOT echo the secret. `skip_serializing` only
10303 /// guarded the serde JSON path; the derived `Debug` leaked it. The
10304 /// manual `Debug` impl redacts the field while preserving the rest.
10305 #[test]
10306 fn app_config_1454_debug_redacts_api_key() {
10307 let cfg = AppConfig {
10308 tier: Some("autonomous".into()),
10309 api_key: Some("HTTP-BEARER-SUPER-SECRET".into()),
10310 ..AppConfig::default()
10311 };
10312 let dbg = format!("{cfg:?}");
10313 assert!(
10314 !dbg.contains("HTTP-BEARER-SUPER-SECRET"),
10315 "AppConfig Debug must redact api_key: {dbg}"
10316 );
10317 assert!(
10318 dbg.contains("<redacted>"),
10319 "AppConfig Debug must show <redacted> placeholder: {dbg}"
10320 );
10321 // Non-secret fields still render so the impl stays useful.
10322 assert!(
10323 dbg.contains("autonomous"),
10324 "AppConfig Debug must still render non-secret fields: {dbg}"
10325 );
10326 }
10327
10328 /// #1454 (SEC, LOW) — a `{:?}` of an `LlmSection` carrying an
10329 /// inline (parse-time-rejected, but still constructable in-memory)
10330 /// `api_key` MUST redact it; the env-var-name / file-path reference
10331 /// fields stay verbatim because they are not secrets.
10332 #[test]
10333 fn llm_section_1454_debug_redacts_api_key() {
10334 let section = LlmSection {
10335 backend: Some("xai".into()),
10336 api_key: Some("LLM-INLINE-SUPER-SECRET".into()),
10337 api_key_env: Some("XAI_API_KEY".into()),
10338 ..LlmSection::default()
10339 };
10340 let dbg = format!("{section:?}");
10341 assert!(
10342 !dbg.contains("LLM-INLINE-SUPER-SECRET"),
10343 "LlmSection Debug must redact api_key: {dbg}"
10344 );
10345 assert!(
10346 dbg.contains("<redacted>"),
10347 "LlmSection Debug must show <redacted> placeholder: {dbg}"
10348 );
10349 assert!(
10350 dbg.contains("XAI_API_KEY"),
10351 "api_key_env (a name, not a secret) must stay verbatim: {dbg}"
10352 );
10353 }
10354}